agentsclimarketplace

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.

Install
npx -y skills add Lonsdale201/wp-agent-skills --skill wp-admin-settings-api

Assembled 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, 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:

IdentifierWhat it groupsFunctions that take it
$option_groupA 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_nameThe actual wp_options row keyregister_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:

  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:

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 contentdo_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

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.