Wp admin settings api
Skill Lonsdale201/wp-agent-skills/wordpress/wp-admin-settings-api
A community-maintained collection of agent skills for WordPress plugin and theme development.
npx -y skills add Lonsdale201/wp-agent-skills --skill wp-admin-settings-apiAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 21 stars21 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
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`.
SKILL.md
17.0 KB, ~3.7k tokens by cl100k_base, as published. Nobody here has run it
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, orshow_in_restin 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
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
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' )
);
} );
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
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
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:
- Verifies the
settings_fields()nonce. - Verifies
current_user_can( 'manage_options' )by default, or a custom cap when you filteroption_page_capability_{$option_group}. - Verifies
$_POST['option_page']matches a registered$option_group. - Calls
update_option()on each option in that group (yoursanitize_callbackruns here). - 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:
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 aregister_setting()'s first arg. Mismatch =options.php(verified line 249) callswp_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()$pageMUST matchdo_settings_sections( $page ). Mismatch = nothing renders (no error — silent failure).- Call
register_setting()onadmin_init, NOT in your menu-page render callback. The menu page only renders when the user views it;options.phpvalidates against the$option_groupregistry, which is built onadmin_initfor 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
miscorprivacyas settings groups/pages. Core maps them togeneral/readingwith deprecated-argument notices. sanitize_callbackruns 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 separateupdate_option_myplugin_optionshook.sanitize_callbackis 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 tomanage_optionsforoptions.php. Override by usingadd_menu_page()with a custom cap AND filteringoption_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-storagefor choosing between options vs custom tables vs user meta — the storage layer beneath the Settings API. - See
wp-settings-storage-auditwhen reviewing the whole settings persistence contract: array shape, defaults, autoload, REST schema, Customizer boundary, update hooks, and deprecations. - See
wp-admin-form-controlsfor color picker / date picker / pointer / autocomplete widgets to drop into field render callbacks. - See
wp-admin-codemirrorwhen a settings field is a CSS/JSON/code textarea. - See
wp-admin-media-framewhen a settings field picks an image or file. - See
wp-rest-apiwhen theshow_in_restdefaults 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_settingis per-site; multisite uses a separateadd_network_options_page/update_site_optionpath. - Customizer settings (
wp.customize). Different API on the frontend; the Settings API is admin-only. - Block-editor settings panels (
@wordpress/componentsPluginSettings, MetaBoxes in the block editor). React-rendered; usesuseEntityPropinstead. - 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.phpwp-admin/includes/template.phpwp-includes/rest-api/endpoints/class-wp-rest-settings-controller.php