How to build custom Sage Roots modern environments extensions utilizing modern WordPress Settings API schemas
Leveraging WordPress Settings API Schemas with Sage Roots for Custom Environments
Modern WordPress development, particularly within frameworks like Sage Roots, demands robust and maintainable solutions for managing application settings. The WordPress Settings API, while powerful, can become unwieldy when dealing with complex configurations or when aiming for a structured, schema-driven approach. This post details how to build custom Sage Roots environment extensions by defining and utilizing modern WordPress Settings API schemas, ensuring cleaner code, better validation, and improved developer experience.
Defining a Schema for Environment-Specific Settings
Instead of directly registering settings fields, we’ll define a JSON schema that describes our desired settings. This schema will dictate the structure, data types, validation rules, and even UI hints for our environment-specific configurations. For this example, let’s consider settings for a custom API integration that might differ between development, staging, and production environments.
We’ll create a JSON file, for instance, config/schemas/api-settings.json within your Sage Roots theme or a dedicated plugin. This schema will serve as the single source of truth for our API settings.
Example config/schemas/api-settings.json:
{
"title": "API Integration Settings",
"description": "Settings for the external API integration.",
"type": "object",
"properties": {
"api_base_url": {
"title": "API Base URL",
"description": "The base URL for the API endpoint.",
"type": "string",
"format": "url",
"default": "https://api.example.com/v1"
},
"api_key": {
"title": "API Key",
"description": "Your secret API key.",
"type": "string",
"minLength": 32,
"default": ""
},
"timeout_seconds": {
"title": "Request Timeout (seconds)",
"description": "Maximum time to wait for an API response.",
"type": "integer",
"minimum": 5,
"maximum": 60,
"default": 15
},
"enable_logging": {
"title": "Enable API Logging",
"description": "Log API requests and responses for debugging.",
"type": "boolean",
"default": false
}
},
"required": [
"api_base_url",
"api_key"
]
}
Integrating Schema with Sage Roots and Settings API
Sage Roots utilizes a configuration system that can be extended. We’ll hook into the WordPress Settings API registration process, dynamically generating settings fields based on our JSON schema. This approach allows us to keep our schema definition separate and easily manageable.
First, let’s create a PHP class to handle the schema loading and Settings API registration. This could reside in a custom plugin or within your Sage Roots theme’s `app/` directory, perhaps in `app/SchemaSettings.php`.
<?php
namespace App\SchemaSettings;
use Illuminate\Support\Arr;
use WP_Error;
class SchemaSettings {
protected $schema_file = 'config/schemas/api-settings.json';
protected $option_group = 'api_settings_group';
protected $option_name = 'api_settings';
protected $page_slug = 'api-settings';
public function __construct() {
add_action( 'admin_menu', [ $this, 'add_admin_menu' ] );
add_action( 'admin_init', [ $this, 'register_settings' ] );
add_action( 'admin_notices', [ $this, 'display_admin_notices' ] );
}
public function add_admin_menu() {
add_options_page(
__( 'API Settings', 'sage' ),
__( 'API Settings', 'sage' ),
'manage_options',
$this->page_slug,
[ $this, 'render_settings_page' ]
);
}
public function register_settings() {
$schema = $this->load_schema();
if ( ! $schema ) {
return;
}
// Register the main option group
register_setting(
$this->option_group,
$this->option_name,
[ $this, 'sanitize_settings' ]
);
// Dynamically add settings sections and fields based on schema
if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) {
add_settings_section(
'api_settings_section',
__( 'API Configuration', 'sage' ),
[ $this, 'render_section_description' ],
$this->page_slug
);
foreach ( $schema['properties'] as $key => $field_config ) {
$this->add_setting_field( $key, $field_config );
}
}
}
protected function load_schema() {
$path = \Roots\asset_path( $this->schema_file ); // Assuming Sage's asset_path helper
if ( ! file_exists( $path ) ) {
error_log( "Schema file not found: " . $path );
return false;
}
$schema_content = file_get_contents( $path );
$schema = json_decode( $schema_content, true );
if ( json_last_error() !== JSON_ERROR_NONE ) {
error_log( "Error decoding schema JSON: " . json_last_error_msg() );
return false;
}
return $schema;
}
protected function add_setting_field( $key, $config ) {
add_settings_field(
$key,
isset( $config['title'] ) ? $config['title'] : ucwords( str_replace( '_', ' ', $key ) ),
[ $this, 'render_field' ],
$this->page_slug,
'api_settings_section',
[
'label_for' => $key,
'config' => $config,
'value' => $this->get_setting( $key ),
]
);
}
public function render_section_description() {
$schema = $this->load_schema();
if ( $schema && isset( $schema['description'] ) ) {
echo '<p>' . esc_html( $schema['description'] ) . '</p>';
}
}
public function render_field( $args ) {
$key = $args['label_for'];
$config = $args['config'];
$value = $args['value'];
$field_type = $config['type'] ?? 'text';
$title = isset( $config['title'] ) ? $config['title'] : ucwords( str_replace( '_', ' ', $key ) );
$description = isset( $config['description'] ) ? $config['description'] : '';
$required = in_array( $key, $config['required'] ?? [] );
// Basic type mapping to HTML input types
$input_type = 'text';
switch ( $field_type ) {
case 'string':
if ( isset( $config['format'] ) && $config['format'] === 'url' ) {
$input_type = 'url';
} elseif ( isset( $config['format'] ) && $config['format'] === 'email' ) {
$input_type = 'email';
}
// Add more formats like 'password' if needed
break;
case 'integer':
case 'number':
$input_type = 'number';
break;
case 'boolean':
$input_type = 'checkbox';
break;
default:
$input_type = 'text';
}
// Handle specific types like checkboxes
if ( $input_type === 'checkbox' ) {
printf(
'<input type="checkbox" id="%1$s" name="%2$s[%1$s]" value="1" %3$s /><label for="%1$s"> %4$s</label>',
esc_attr( $key ),
esc_attr( $this->option_name ),
checked( 1, $value, false ),
esc_html( $title )
);
} else {
printf(
'<input type="%1$s" id="%2$s" name="%3$s[%2$s]" value="%4$s" class="regular-text" %5$s />',
esc_attr( $input_type ),
esc_attr( $key ),
esc_attr( $this->option_name ),
esc_attr( $value ),
isset( $config['required'] ) && in_array( $key, $config['required'] ) ? 'required' : ''
);
if ( $input_type === 'number' ) {
if ( isset( $config['minimum'] ) ) {
printf( ' min="%s"', esc_attr( $config['minimum'] ) );
}
if ( isset( $config['maximum'] ) ) {
printf( ' max="%s"', esc_attr( $config['maximum'] ) );
}
}
}
if ( ! empty( $description ) ) {
printf( '<p class="description">%s</p>', esc_html( $description ) );
}
}
public function sanitize_settings( $input ) {
$schema = $this->load_schema();
if ( ! $schema || ! isset( $schema['properties'] ) ) {
return $input;
}
$sanitized_input = [];
$current_options = get_option( $this->option_name, [] );
foreach ( $schema['properties'] as $key => $config ) {
$value = Arr::get( $input, $key );
$default_value = $config['default'] ?? null;
// Handle boolean/checkbox specifically
if ( $config['type'] === 'boolean' ) {
$sanitized_input[$key] = (bool) ( $value ?? false );
continue;
}
// If value is not provided, use default or keep existing if available
if ( $value === null || $value === '' ) {
$sanitized_input[$key] = $current_options[$key] ?? $default_value;
continue;
}
// Basic sanitization based on type
switch ( $config['type'] ) {
case 'string':
$sanitized_input[$key] = sanitize_text_field( $value );
if ( isset( $config['format'] ) ) {
if ( $config['format'] === 'url' ) {
$sanitized_input[$key] = esc_url_raw( $value );
} elseif ( $config['format'] === 'email' ) {
$sanitized_input[$key] = sanitize_email( $value );
}
}
break;
case 'integer':
case 'number':
$sanitized_input[$key] = filter_var( $value, FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION );
if ( $config['type'] === 'integer' ) {
$sanitized_input[$key] = intval( $sanitized_input[$key] );
}
break;
default:
$sanitized_input[$key] = sanitize_text_field( $value );
}
// Apply validation rules from schema (basic example)
if ( isset( $config['minLength'] ) && strlen( $sanitized_input[$key] ) < $config['minLength'] ) {
add_settings_error( $this->option_name, 'minLength_error', sprintf( __( 'Field "%s" must be at least %d characters long.', 'sage' ), $config['title'], $config['minLength'] ), 'error' );
}
if ( isset( $config['minimum'] ) && $sanitized_input[$key] < $config['minimum'] ) {
add_settings_error( $this->option_name, 'minimum_error', sprintf( __( 'Field "%s" must be at least %d.', 'sage' ), $config['title'], $config['minimum'] ), 'error' );
}
if ( isset( $config['maximum'] ) && $sanitized_input[$key] > $config['maximum'] ) {
add_settings_error( $this->option_name, 'maximum_error', sprintf( __( 'Field "%s" must be no more than %d.', 'sage' ), $config['title'], $config['maximum'] ), 'error' );
}
}
// Check for required fields that were not provided
if ( isset( $schema['required'] ) ) {
foreach ( $schema['required'] as $required_key ) {
if ( ! isset( $sanitized_input[$required_key] ) || $sanitized_input[$required_key] === '' ) {
$field_title = $schema['properties'][$required_key]['title'] ?? ucwords( str_replace( '_', ' ', $required_key ) );
add_settings_error( $this->option_name, 'required_field_error', sprintf( __( 'Field "%s" is required.', 'sage' ), $field_title ), 'error' );
}
}
}
// If errors occurred, return the input as is to show errors, otherwise return sanitized
if ( ! empty( $GLOBALS['wp_settings_errors'] ) ) {
// Filter for errors related to our option group
$our_errors = array_filter( $GLOBALS['wp_settings_errors'], function( $error ) {
return $error['setting'] === $this->option_name;
} );
if ( ! empty( $our_errors ) ) {
return $input; // Return original input to preserve user data on error
}
}
return $sanitized_input;
}
public function render_settings_page() {
?>
option_name, 'settings_updated', __( 'Settings saved.', 'sage' ), 'success' );
}
settings_errors( $this->option_name );
}
/**
* Get a specific setting value.
*
* @param string $key The setting key.
* @param mixed $default The default value if not found.
* @return mixed The setting value.
*/
public function get_setting( string $key, $default = null ) {
$options = get_option( $this->option_name, [] );
return Arr::get( $options, $key, $default );
}
/**
* Get all settings.
*
* @return array
*/
public function get_all_settings() {
return get_option( $this->option_name, [] );
}
}
// Instantiate the class to register hooks
new SchemaSettings();
Explanation:
- `$schema_file`: Path to our JSON schema definition relative to Sage’s asset path.
- `$option_group`, `$option_name`, `$page_slug`: Standard WordPress Settings API identifiers.
- `add_admin_menu()`: Registers a new submenu page under ‘Settings’ for our API settings.
- `register_settings()`: This is the core. It loads the schema, registers the main option group, and then iterates through the schema’s
propertiesto dynamically add settings sections and fields usingadd_settings_sectionandadd_settings_field. - `load_schema()`: Safely loads and decodes the JSON schema file. It uses Sage’s
asset_path()helper to resolve the path correctly. - `add_setting_field()`: A helper to call
add_settings_fieldfor each property in the schema, passing configuration and current values. - `render_section_description()`: Displays the schema’s top-level description.
- `render_field()`: Dynamically renders the HTML input for each setting based on the schema’s
typeandformat. It handles basic types like text, number, and checkbox. - `sanitize_settings()`: This crucial method receives the raw input from the form. It iterates through the schema again, applying sanitization based on the defined types and formats. It also includes basic validation checks (minLength, minimum, maximum, required) and adds errors using
add_settings_error. If validation errors occur, it returns the original input to prevent data loss and display the errors. - `render_settings_page()`: Renders the standard WordPress settings page structure, including form, settings fields, and submit button.
- `display_admin_notices()`: Shows success or error messages after form submission.
- `get_setting()` / `get_all_settings()`: Utility methods to easily retrieve saved settings elsewhere in your application.
Environment-Specific Overrides
The real power comes when we combine this schema-driven approach with environment-specific configurations. Sage Roots typically uses environment variables (e.g., via `.env` files) to manage settings across different deployment stages. We can leverage these variables to override default or saved settings.
Modify the SchemaSettings::get_setting() and SchemaSettings::get_all_settings() methods to check for environment variables. We’ll assume environment variables are prefixed, e.g., API_KEY, API_BASE_URL.
// Inside App\SchemaSettings\SchemaSettings class
// ... (previous methods)
/**
* Get a specific setting value, checking environment variables first.
*
* @param string $key The setting key.
* @param mixed $default The default value if not found.
* @return mixed The setting value.
*/
public function get_setting( string $key, $default = null ) {
$env_var_name = strtoupper( $key ); // Simple mapping, adjust as needed
if ( getenv( $env_var_name ) !== false ) {
return getenv( $env_var_name );
}
$options = get_option( $this->option_name, [] );
return Arr::get( $options, $key, $default );
}
/**
* Get all settings, checking environment variables first.
*
* @return array
*/
public function get_all_settings() {
$saved_settings = get_option( $this->option_name, [] );
$schema = $this->load_schema();
$all_settings = [];
if ( ! $schema || ! isset( $schema['properties'] ) ) {
return $saved_settings; // Fallback if schema is missing
}
foreach ( $schema['properties'] as $key => $config ) {
$env_var_name = strtoupper( $key );
if ( getenv( $env_var_name ) !== false ) {
$all_settings[$key] = getenv( $env_var_name );
} else {
$all_settings[$key] = $saved_settings[$key] ?? $config['default'] ?? null;
}
}
return $all_settings;
}
// ... (rest of the class)
With these modifications, when the application runs in a specific environment (e.g., production), environment variables like API_KEY will take precedence over saved options in the database. This is crucial for security (e.g., not storing production API keys in the database) and for managing different configurations seamlessly.
Accessing Settings in Your Theme/Plugin
You can now access your API settings anywhere in your Sage Roots theme or plugin. It’s recommended to create a helper function or a service class to abstract this access.
// Example: In a helper file or service provider
use App\SchemaSettings\SchemaSettings;
use Illuminate\Support\Arr;
/**
* Get a specific API setting.
*
* @param string $key The setting key (e.g., 'api_base_url').
* @param mixed $default Default value if not found.
* @return mixed
*/
function get_api_setting( string $key, $default = null ) {
static $settings = null;
if ( $settings === null ) {
// Instantiate the class to get access to its methods
$schema_settings = new SchemaSettings();
$settings = $schema_settings->get_all_settings();
}
return Arr::get( $settings, $key, $default );
}
// Usage example:
$api_key = get_api_setting('api_key');
$base_url = get_api_setting('api_base_url');
$timeout = get_api_setting('timeout_seconds', 30); // Provide a fallback timeout
if ( ! empty( $api_key ) ) {
// Use the API key and base URL
// e.g., wp_remote_post( $base_url . '/resource', ['headers' => ['Authorization' => 'Bearer ' . $api_key]] );
}
Advanced Considerations and Next Steps
- Schema Validation Libraries: For more robust validation, consider integrating a JSON Schema validation library (like `justinrainbow/json-schema` for PHP) within the
sanitize_settingsmethod to strictly enforce schema rules beyond basic type checks. - Complex Field Types: The
render_fieldmethod can be extended to handle more complex field types like textareas, select dropdowns, color pickers, or even repeatable fields, by adding more logic based on schema properties (e.g., an `enum` property for select boxes). - UI/UX Enhancements: Use schema properties like
descriptionfor tooltips or help text. You could also add custom attributes to the schema (e.g.,"ui": {"component": "colorpicker"}) and use JavaScript in the admin to render richer UI elements. - Internationalization (i18n): Ensure all user-facing strings in the schema (titles, descriptions) and in the PHP code are translatable using WordPress’s internationalization functions (e.g.,
__(),_e()). - Security: Always sanitize and validate input thoroughly. For sensitive fields like API keys, ensure they are never exposed client-side and are preferably handled via environment variables. The
sanitize_text_fieldandesc_url_raware good starting points, but tailor them to your specific needs. - Sage Roots Configuration Integration: For deeper integration, explore Sage’s configuration system. You might store the schema definition path within Sage’s
config/app.phpor similar, making it more configurable.
By adopting a schema-driven approach for your WordPress settings, especially within a framework like Sage Roots, you create a more organized, maintainable, and extensible system for managing configurations across different environments. This pattern promotes best practices in application development and significantly enhances the developer experience.