reference.md
# Settings API Reference Examples
## Field Render Callbacks
```php
function myplugin_render_field_enabled(): void {
$options = get_option( 'myplugin_options', array() );
$value = ! empty( $options['enabled'] );
?>
<input type="hidden" name="myplugin_options[enabled]" value="0" />
<input
type="checkbox"
id="myplugin_enabled"
name="myplugin_options[enabled]"
value="1"
<?php checked( $value ); ?>
/>
<span class="description"><?php esc_html_e( 'Turn the plugin on globally.', 'myplugin' ); ?></span>
<?php
}
function myplugin_render_field_api_key(): void {
$options = get_option( 'myplugin_options', array() );
$configured = ! empty( $options['api_key'] );
?>
<input
type="password"
id="myplugin_api_key"
name="myplugin_options[api_key]"
class="regular-text"
value=""
autocomplete="new-password"
/>
<?php if ( $configured ) : ?>
<label>
<input type="checkbox" name="myplugin_options[clear_api_key]" value="1" />
<?php esc_html_e( 'Remove the configured API key', 'myplugin' ); ?>
</label>
<?php endif; ?>
<?php
}
```
## Tabs Pattern
```php
function myplugin_render_settings_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Not allowed.', 'myplugin' ), 403 );
}
$tabs = array(
'general' => __( 'General', 'myplugin' ),
'integrations' => __( 'Integrations', 'myplugin' ),
'advanced' => __( 'Advanced', 'myplugin' ),
);
$requested_tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'general';
$active = isset( $tabs[ $requested_tab ] ) ? $requested_tab : 'general';
?>
<div class="wrap">
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
<nav class="nav-tab-wrapper">
<?php foreach ( $tabs as $slug => $label ) :
$url = add_query_arg( array( 'page' => 'myplugin', 'tab' => $slug ), admin_url( 'options-general.php' ) );
$class = 'nav-tab' . ( $slug === $active ? ' nav-tab-active' : '' );
?>
<a href="<?php echo esc_url( $url ); ?>" class="<?php echo esc_attr( $class ); ?>">
<?php echo esc_html( $label ); ?>
</a>
<?php endforeach; ?>
</nav>
<?php settings_errors(); ?>
<form method="post" action="options.php">
<?php
settings_fields( 'myplugin_' . $active );
do_settings_sections( 'myplugin_' . $active );
submit_button();
?>
</form>
</div>
<?php
}
```
```php
$setting_args = array(
'type' => 'object',
'default' => array( 'enabled' => false, 'api_key' => '', 'log_level' => 'info' ),
'show_in_rest' => false,
'sanitize_callback' => 'myplugin_sanitize_options',
);
register_setting( 'myplugin_general', 'myplugin_options', $setting_args );
register_setting( 'myplugin_integrations', 'myplugin_options', $setting_args );
register_setting( 'myplugin_advanced', 'myplugin_options', $setting_args );
add_settings_section( 'myplugin_section_general', /* ... */ 'myplugin_general' );
add_settings_section( 'myplugin_section_integrations', /* ... */ 'myplugin_integrations' );
```
Use the same complete args in all three registrations. Re-registering the same
option name replaces its global metadata.
## REST Schema for Object Settings
```php
// Expose only non-secret settings. Keep API keys in a separate non-REST option.
register_setting( 'myplugin', 'myplugin_public_options', array(
'type' => 'object',
'default' => array( 'enabled' => false, 'log_level' => 'info' ),
'show_in_rest' => array(
'schema' => array(
'type' => 'object',
'additionalProperties' => false,
'properties' => array(
'enabled' => array( 'type' => 'boolean' ),
'log_level' => array( 'type' => 'string', 'enum' => array( 'debug', 'info', 'warn', 'error' ) ),
),
),
),
'sanitize_callback' => 'myplugin_sanitize_options',
) );
```
## Flash Messages
```php
function myplugin_sanitize_options( $input ): array {
if ( $api_key_was_bad ) {
add_settings_error(
'myplugin_options',
'api_key_invalid',
__( 'The API key format is invalid.', 'myplugin' ),
'error'
);
}
if ( $cleared ) {
add_settings_error(
'myplugin_options',
'cleared',
__( 'Cache cleared.', 'myplugin' ),
'success'
);
}
return $clean;
}
```
```php
settings_errors( 'myplugin_options' );
```
## One Option Per Field
```php
register_setting( 'myplugin', 'myplugin_api_key', array( 'sanitize_callback' => 'sanitize_text_field' ) );
register_setting( 'myplugin', 'myplugin_log_level', array( 'sanitize_callback' => 'sanitize_key' ) );
```
Use this only when another plugin, integration, or operational workflow needs stable independent option names.
## Common Mistakes
```php
// WRONG: posts to own handler and loses core's nonce/cap/allowlist.
?>
<form method="post" action="">
<input type="hidden" name="myplugin_save" value="1" />
<?php wp_nonce_field( 'myplugin_save' ); ?>
<input name="api_key" />
<?php submit_button(); ?>
</form>
<?php
if ( isset( $_POST['myplugin_save'] ) ) {
check_admin_referer( 'myplugin_save' );
update_option( 'myplugin_api_key', sanitize_text_field( $_POST['api_key'] ) );
}
// RIGHT.
?>
<form method="post" action="options.php">
<?php settings_fields( 'myplugin' ); ?>
<?php do_settings_sections( 'myplugin' ); ?>
<?php submit_button(); ?>
</form>
```
```php
// WRONG: register_setting only runs when this page is viewed.
function myplugin_render_settings_page() {
register_setting( 'myplugin', 'myplugin_options', /* ... */ );
}
// RIGHT: admin_init runs on options.php submits too.
add_action( 'admin_init', static function () {
register_setting( 'myplugin', 'myplugin_options', /* ... */ );
} );
```
```php
// WRONG: mismatched slugs.
register_setting( 'my_plugin', 'myplugin_options', /* ... */ );
add_settings_section( 'sec', 'General', $cb, 'myplugin' );
settings_fields( 'myplugin' );
do_settings_sections( 'my-plugin' );
// RIGHT: use one slug consistently.
register_setting( 'myplugin', 'myplugin_options', /* ... */ );
add_settings_section( 'sec', 'General', $cb, 'myplugin' );
settings_fields( 'myplugin' );
do_settings_sections( 'myplugin' );
```
```php
// WRONG: tab A's save wipes tab B's fields.
function myplugin_sanitize_options( $input ) {
return array(
'api_key' => sanitize_text_field( $input['api_key'] ?? '' ),
'log_level' => $input['log_level'] ?? 'info',
);
}
// RIGHT: merge over existing.
function myplugin_sanitize_options( $input ) {
$clean = get_option( 'myplugin_options', array() );
if ( isset( $input['api_key'] ) ) {
$clean['api_key'] = sanitize_text_field( $input['api_key'] );
}
if ( isset( $input['log_level'] ) ) {
$clean['log_level'] = sanitize_key( $input['log_level'] );
}
return $clean;
}
```
```php
// WRONG: sanitize callback sends an email every save.
function myplugin_sanitize_options( $input ) {
wp_mail( 'admin@example.com', 'Settings changed', '...' );
return $clean;
}
// RIGHT: side effects belong in update_option_*.
add_action( 'update_option_myplugin_options', static function ( $old, $new ): void {
if ( ( $old['api_key'] ?? '' ) !== ( $new['api_key'] ?? '' ) ) {
wp_mail( 'admin@example.com', 'API key changed', '...' );
}
}, 10, 2 );
```
SKILL.md
---
name: wp-admin-settings-api
description: Build plugin admin settings pages with the WordPress Settings API instead of custom form handlers. Covers `register_setting()`, `add_settings_section()`, `add_settings_field()`, `settings_fields()`, `do_settings_sections()`, `add_settings_error()`, `settings_errors()`, `admin_init` registration, `form method="post" action="options.php"`, `sanitize_callback`, `$option_group` vs `$page`, single-array option storage, keyed field names, tabbed pages, `show_in_rest` schemas including object/array schemas, custom option capabilities via `option_page_capability_{$option_group}`, deprecated settings groups, and the mistake of POSTing to your own handler. Use for plugin settings screens, integration config, feature toggles, or any options page that saves to `wp_options`.
metadata:
wp-skills-author: "Soczó Kristóf"
wp-skills-contact: "mailto:lonsdale201@hotmail.com"
wp-skills-plugin: "wordpress"
wp-skills-plugin-version-tested: "6.0 - 7.1"
wp-skills-wp-version-tested: "7.1"
wp-skills-php-min: "7.4"
wp-skills-last-updated: "2026-08-20"
---
# WordPress Settings API
The Settings API exists so you don't write your own form handling, nonce verification, capability check, sanitization dispatch, error flashing, and option storage. Core does all of it — you describe the form. Plugins that bypass it (POSTing to a custom admin-post handler, writing their own nonce + cap check) end up with worse security and worse a11y than the boring built-in path.
## When to use this skill
Trigger when ANY of the following is true:
- A plugin needs an admin settings page that saves option values.
- Code references `register_setting`, `add_settings_section`, `add_settings_field`, `settings_fields`, `do_settings_sections`, `add_settings_error`, `settings_errors`, `options.php`, `sanitize_callback`, or `show_in_rest` in a plugin context.
- The user is about to write `<form method="post" action="<?php echo $_SERVER['REQUEST_URI']; ?>">` and a manual handler — the Settings API is the right answer.
- The user complains: "my settings don't save", "settings_fields nonce mismatch", "options page registered but field doesn't show", "sanitize_callback runs twice", "tabs don't persist data when I switch".
## The mental model — three IDs, easy to confuse
The Settings API uses three identifier types and reusing them inconsistently is the single most common debugging dead-end:
| Identifier | What it groups | Functions that take it |
|---|---|---|
| `$option_group` | A *nonce-protected save batch*. `options.php` accepts a POST only if its `option_page` field matches one of these. | `register_setting( $option_group, ... )`, `settings_fields( $option_group )` |
| `$page` (settings page slug) | A *rendering target* — collects sections and fields to render together. | `add_settings_section( ..., $page, ... )`, `add_settings_field( ..., $page, ... )`, `do_settings_sections( $page )` |
| `$option_name` | The actual `wp_options` row key | `register_setting( $group, $option_name, ... )`, `get_option( $option_name )` |
You CAN use the same string for `$option_group` and `$page` — that's the common simplification. But if you set the menu page slug to one thing, the section page to another, and `settings_fields()` to a third, the result is: nothing renders (silent — `do_settings_sections` finds no matching sections) AND the save dies loudly via `wp_die()` from `options.php` ("not in the allowed options list"). Two distinct failure modes from the same slug-drift bug. **Pick one slug, reuse it.**
## The full bootstrap — single-option-array pattern
The pattern below stores ALL plugin settings in a SINGLE serialized array in `wp_options` (`myplugin_options`). This is the WP-recommended approach — one row, one autoload entry, less query overhead than one-option-per-field.
### 1. Register the menu page
```php
add_action( 'admin_menu', static function (): void {
add_options_page(
__( 'My Plugin', 'myplugin' ),
__( 'My Plugin', 'myplugin' ),
'manage_options',
'myplugin', // the admin page slug — also our $page identifier
'myplugin_render_settings_page'
);
} );
```
`add_options_page()` registers under Settings → My Plugin. For a top-level entry use `add_menu_page()`; for an integration tab use `add_submenu_page()`.
### 2. Register sections, fields, and the setting itself on `admin_init`
```php
add_action( 'admin_init', static function (): void {
register_setting(
'myplugin', // $option_group — used by settings_fields() in the form
'myplugin_options', // $option_name — the wp_options row key
array(
'type' => 'object',
'default' => array(
'enabled' => false,
'api_key' => '',
'log_level' => 'info',
),
'sanitize_callback' => 'myplugin_sanitize_options',
'show_in_rest' => false, // set to a schema array to expose via REST
)
);
add_settings_section(
'myplugin_section_general', // section id
__( 'General', 'myplugin' ), // section title
static function (): void {
echo '<p>' . esc_html__( 'General plugin configuration.', 'myplugin' ) . '</p>';
},
'myplugin' // $page — must match do_settings_sections() below
);
add_settings_field(
'enabled',
__( 'Enable plugin', 'myplugin' ),
'myplugin_render_field_enabled',
'myplugin', // $page
'myplugin_section_general',
array( 'label_for' => 'myplugin_enabled' ) // sets the section <th>'s <label for>
);
add_settings_field(
'api_key',
__( 'API key', 'myplugin' ),
'myplugin_render_field_api_key',
'myplugin',
'myplugin_section_general',
array( 'label_for' => 'myplugin_api_key' )
);
} );
```
WordPress 7.1 gives each non-empty section title a generated unique `<h2 id>`
when `do_settings_sections()` renders it. This improves programmatic heading
association, but the ID contains a `wp_unique_id()` suffix and is not a stable
plugin selector. Use your own wrapper/classes or the section slug for CSS/JS;
do not parse or persist the generated heading ID. Repeated section rendering in
one request intentionally yields different IDs.
### 3. Field render callbacks
Render callbacks echo normal form controls. The `name` attribute ties the input to the `$option_name` array: `myplugin_options[api_key]` lands in `$_POST['myplugin_options']['api_key']` and arrives at `sanitize_callback` as `$input['api_key']`. See `reference.md` for checkbox and text-field callbacks.
Treat the option array as a schema, not a loose bucket: every subkey must have a default, one type, one meaning, and a sanitizer. Drop unknown keys unless forward compatibility is deliberate.
### 4. The single sanitize callback
```php
function myplugin_sanitize_options( $input ): array {
$defaults = array( 'enabled' => false, 'api_key' => '', 'log_level' => 'info' );
$input = is_array( $input ) ? $input : array();
$existing = get_option( 'myplugin_options', $defaults );
$existing = is_array( $existing ) ? array_replace( $defaults, $existing ) : $defaults;
$clean = $existing; // start from current saved state so untouched tabs don't get blanked
// A hidden enabled=0 input makes this key present only on the tab that owns it.
if ( array_key_exists( 'enabled', $input ) ) {
$clean['enabled'] = ! empty( $input['enabled'] );
}
// A blank password field means "unchanged"; clearing is explicit.
if ( ! empty( $input['clear_api_key'] ) ) {
$clean['api_key'] = '';
} elseif ( isset( $input['api_key'] ) && '' !== trim( (string) $input['api_key'] ) ) {
$api_key = trim( (string) $input['api_key'] );
if ( ! preg_match( '/^[A-Za-z0-9_-]{20,}$/', $api_key ) ) {
add_settings_error( 'myplugin_options', 'api_key_invalid',
__( 'API key format is invalid.', 'myplugin' ) );
// Keep the existing value rather than letting the bad one through.
} else {
$clean['api_key'] = $api_key;
}
}
// Enum.
$allowed_levels = array( 'debug', 'info', 'warn', 'error' );
if ( isset( $input['log_level'] ) && in_array( $input['log_level'], $allowed_levels, true ) ) {
$clean['log_level'] = $input['log_level'];
}
return $clean;
}
```
The "start from `$existing`" pattern is critical when you have tabs (see below). A tab only submits its own fields; without this guard, switching tabs and saving wipes the others.
### 5. The page render — the form that POSTs to `options.php`
```php
function myplugin_render_settings_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You are not allowed to access this page.', 'myplugin' ), 403 );
}
?>
<div class="wrap">
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
<?php settings_errors(); ?>
<form method="post" action="options.php">
<?php
// Emits the nonce, option_page, action="update" hidden fields.
settings_fields( 'myplugin' );
// Renders all sections + fields registered against $page = 'myplugin'.
do_settings_sections( 'myplugin' );
submit_button();
?>
</form>
</div>
<?php
}
```
For a classic per-site Settings API form, the action `options.php` is
**mandatory**. That's the core handler that:
1. Verifies the `settings_fields()` nonce.
2. Verifies `current_user_can( 'manage_options' )` by default, or a custom cap when you filter `option_page_capability_{$option_group}`.
3. Verifies `$_POST['option_page']` matches a registered `$option_group`.
4. Calls `update_option()` on each option in that group (your `sanitize_callback` runs here).
5. Flashes the standard "Settings saved" notice and redirects back to your page with `?settings-updated=true`.
Custom `admin-post.php` handlers and multisite network settings are valid
different architectures, but then you own the full nonce, capability,
allowlist, validation, update, and redirect flow.
## Tabs pattern
The Settings API doesn't ship tabs — you build them. Use one `$_GET['tab']`
query var, one form per tab posting to `options.php`, and separate
`$option_group` / `$page` slugs per tab. All tabs can write the same
`$option_name` only when every `register_setting()` call supplies identical
type/default/schema/sanitizer metadata: registration is global by option name,
so a later call replaces those args. The sanitizer must merge over existing
state and only overwrite keys submitted by that tab.
## Surfacing settings in REST / Site Editor — `show_in_rest`
When `show_in_rest => true` (or a schema array), the setting becomes readable / writable at `/wp/v2/settings`. Useful for block-editor side panels, headless frontends, or CLI tools.
REST writes go through the same `sanitize_callback`. Important: REST permissions default to `manage_options`-or-equivalent on `/wp/v2/settings` — fine for plugin-admin settings, NOT fine if you want a lower-privileged user to update a subset. For per-cap REST writes, register a dedicated REST route instead.
For object settings, pass a schema with `properties`; see `reference.md`.
For array settings, core requires `show_in_rest.schema.items` when `show_in_rest` is not false. Without it, `register_setting()` triggers `_doing_it_wrong()`. For object settings, define `properties` and keep `additionalProperties` false unless arbitrary keys are intentional.
## Flash messages — `add_settings_error` + `settings_errors`
`add_settings_error( $setting, $code, $message, $type )` — `$type` is `'error' | 'warning' | 'info' | 'success' | 'updated'`. Errors are queued during sanitize and flashed on the next render.
Then in your page render, call `settings_errors()` once (above the form). The default "Settings saved" notice from `options.php` is added automatically with code `settings_updated`. Pass the setting slug when you want to show only that setting's messages:
```php
settings_errors( 'myplugin_options' );
```
Do not use `$hide_on_update = true` as a way to hide the default saved notice after submit; it suppresses all messages while `settings-updated` is present. Reserve it for first-load diagnostics that should disappear after a save redirect.
## Single-array vs one-option-per-field
The bootstrap above uses ONE option (`myplugin_options`) holding an array. This
is a good default for cohesive, similarly accessed settings. Separate cold,
large, independently owned, or sensitive values when they need different
autoload/access/lifecycle behavior; an API secret does not need to ride in an
otherwise hot autoloaded UI-options array. See `reference.md` for the alternate
registration shape.
## Critical rules
- **Classic per-site Settings API forms post to `options.php`**. Posting that
same form to your page discards core's nonce, capability, and allowed-option
flow. A deliberately custom handler is a different architecture and must
reimplement those controls explicitly.
- **`settings_fields( $option_group )` MUST match a `register_setting()`'s first arg**. Mismatch = `options.php` (verified line 249) calls `wp_die()` with the message *"Error: The `<group>` options page is not in the allowed options list."* — the form post is dropped before any sanitize callback runs.
- **`add_settings_section()` / `add_settings_field()` `$page` MUST match `do_settings_sections( $page )`**. Mismatch = nothing renders (no error — silent failure).
- **Call `register_setting()` on `admin_init`, NOT in your menu-page render callback**. The menu page only renders when the user views it; `options.php` validates against the `$option_group` registry, which is built on `admin_init` for every admin request.
- **Do not rely on unregistered option posts to `options.php`**. Core treats unregistered settings as deprecated. Register every option that the form saves.
- **Do not use `misc` or `privacy` as settings groups/pages**. Core maps them to `general` / `reading` with deprecated-argument notices.
- **`sanitize_callback` runs on every save, including REST writes**. Don't put side effects (sending emails, calling external APIs) directly in it — return the cleaned value. Side effects belong in a separate `update_option_myplugin_options` hook.
- **`sanitize_callback` is called with the full submitted array for array options**. For tabs to coexist, start from the existing saved value and only overwrite keys present in `$input`.
- **`current_user_can()` defaults to `manage_options` for `options.php`**. Override by using `add_menu_page()` with a custom cap AND filtering `option_page_capability_{$option_group}` to require the same.
- **Don't echo competing headings in the section callback for accessibility-critical content** — `do_settings_sections()` prints the section `<h2>` first, then runs the callback directly under it. Use descriptive `<p>` text unless you intentionally need more heading structure.
## Common AI mistakes
See `reference.md` for before/after snippets: posting to your own handler, registering settings inside the render callback, mismatched group/page slugs, tab saves that wipe other tab data, and side effects inside `sanitize_callback`.
## Cross-references
- See **`wp-plugin-options-storage`** for choosing between options vs custom tables vs user meta — the storage layer beneath the Settings API.
- See **`wp-settings-storage-audit`** when reviewing the whole settings persistence contract: array shape, defaults, autoload, REST schema, Customizer boundary, update hooks, and deprecations.
- See **`wp-admin-form-controls`** for color picker / date picker / pointer / autocomplete widgets to drop into field render callbacks.
- See **`wp-admin-codemirror`** when a settings field is a CSS/JSON/code textarea.
- See **`wp-admin-media-frame`** when a settings field picks an image or file.
- See **`wp-rest-api`** when the `show_in_rest` defaults don't fit your auth model and you need a custom REST route instead.
## What this skill does NOT cover
- Network-wide / multisite settings. `register_setting` is per-site; multisite uses a separate `add_network_options_page` / `update_site_option` path.
- Customizer settings (`wp.customize`). Different API on the frontend; the Settings API is admin-only.
- Block-editor settings panels (`@wordpress/components` PluginSettings, MetaBoxes in the block editor). React-rendered; uses `useEntityProp` instead.
- Building a settings page UI with React/Gutenberg components inside admin. Possible (`createRoot` + WP REST settings endpoints), but a separate topic from the classic Settings API.
## References
- `wp-admin/includes/plugin.php:2347` — `settings_fields()` (emits the nonce + option_page + action hidden fields).
- `wp-admin/options.php` — the core handler your form posts to; read it to understand what verification you get for free.
- `reference.md` — tabs, REST schema, flash messages, one-option-per-field, and common mistakes.
- Official documentation: <https://developer.wordpress.org/reference/functions/register_setting/>
- Official documentation: <https://developer.wordpress.org/reference/functions/add_settings_section/>
- Official documentation: <https://developer.wordpress.org/reference/functions/add_settings_field/>
- Official documentation: <https://developer.wordpress.org/plugins/settings/settings-api/>
- Verified source paths:
- `wp-includes/option.php`
- `wp-admin/includes/template.php`
- `wp-includes/rest-api/endpoints/class-wp-rest-settings-controller.php`