Skip to content
System nominalUTC --:--:--Articles 080
MODULE 03 Lesson 07 / 18 25 MIN

The admin side

The Settings API

How register_setting, add_settings_section and add_settings_field fit together, and what options.php does on save.

You build a settings page, save it once, reload, and the field is empty. The save reported success. If you look in wp_options the row exists and holds a value that is not the one you typed. Nothing in the admin told you anything was wrong, because nothing in the admin considers this an error.

That sequence has one common cause: a field named wpsifero_accent posted into a form whose option_page belongs to a group that never registered wpsifero_accent. WordPress accepts the POST, ignores the value it does not recognise, and redirects back with settings-updated=true. The save succeeded and the data went nowhere.

The Settings API is three registration functions and one endpoint, and the failure above is a direct consequence of how the endpoint decides what to write. Once you have the model, the API is small: everything hangs off the fact that wp-admin/options.php will only write options that some callback, somewhere, declared an interest in.

register_setting is a declaration, not a form field

Calling register_setting( $option_group, $option_name, $args ) does four separate things. It appends $option_name to $new_allowed_options[ $option_group ]. It stores $args in $wp_registered_settings[ $option_name ]. It adds the sanitize_callback, when there is one, as a filter on sanitize_option_{$option_name}. And a default key hooks default_option_{$option_name}, so get_option() can answer before the row exists.

The function takes no field definition, no label and no input markup. It registers an option name and the behaviours attached to that name. Drawing the form is a separate function, which is why you can register a setting and forget to render a field for it, or render a field for a setting you never registered. The first is harmless. The second is the bug at the top of this lesson.

register_setting(
    'wpsifero_badge',
    'wpsifero_accent',
    array(
        'type'              => 'string',
        'label'             => __( 'Badge colour', 'wpsifero' ),
        'description'       => __( 'Hex colour used by the front-end badge.', 'wpsifero' ),
        'default'           => '#2271b1',
        'sanitize_callback' => 'wpsifero_sanitize_hex_colour',
        'show_in_rest'      => true,
    )
);

The keys that change behaviour are sanitize_callback, show_in_rest, type and default. The rest, label and description, are read by the REST controller when it builds a schema, and by nothing else in core. They are worth filling in because they show up in the REST API response and in the block editor's generated controls, not because the settings page uses them.

Without show_in_rest the option is invisible to the REST API

show_in_rest defaults to false. While it is false, the setting is absent from get_registered_options() in the REST settings controller, which builds the schema behind GET /wp/v2/settings. That endpoint is how editor JavaScript reads and writes site options. A plugin that registers a setting the block editor needs and leaves show_in_rest at its default has written an option the editor cannot see, and nothing raises an error.

Two conditions apply, both visible in the controller source. It skips any setting whose schema has no type, and it skips types outside number, integer, string, boolean, array and object. Since register_setting() defaults type to string, the common case works. An array setting with show_in_rest true also has to describe its items, so its value becomes an array carrying a schema.items entry.

register_setting(
    'wpsifero_badge',
    'wpsifero_badges',
    array(
        'type'         => 'array',
        'default'      => array(),
        'show_in_rest' => array(
            'schema' => array(
                'type'  => 'array',
                'items' => array( 'type' => 'string' ),
            ),
        ),
    )
);

Omit that inner schema and register_setting() calls _doing_it_wrong(), which prints a notice under WP_DEBUG. I ran the registration above with the items key removed and the message was:

register_setting: When registering an "array" setting to show in the REST API, you must
specify the schema for each array item in "show_in_rest.schema.items".

Read access to /wp/v2/settings requires manage_options, so show_in_rest is not a privacy switch. It is a decision about whether the value belongs in the settings payload at all.

Sections and fields are stored separately from the setting

add_settings_section( $id, $title, $callback, $page ) writes an entry into $wp_settings_sections[ $page ][ $id ]. The $page argument is the settings group name, not a URL and not a menu slug. The section callback receives the section array and prints whatever belongs between the heading and the first field.

add_settings_field( $id, $title, $callback, $page, $section, $args ) writes into $wp_settings_fields[ $page ][ $section ][ $id ]. The $section argument defaults to 'default', which is why a field added without a section disappears when your page renders a named section instead. The $args array is passed through to your field callback untouched, and core only looks at two keys inside it: label_for, which wraps the title in a <label> element, and class, which lands on the table row.

add_settings_section(
    'wpsifero_badge_appearance',
    __( 'Appearance', 'wpsifero' ),
    'wpsifero_badge_section_intro',
    'wpsifero_badge'
);

add_settings_field(
    'wpsifero_accent',
    __( 'Badge colour', 'wpsifero' ),
    'wpsifero_badge_colour_field',
    'wpsifero_badge',
    'wpsifero_badge_appearance',
    array( 'label_for' => 'wpsifero_accent' )
);

Register all three on admin_init. That hook fires on every admin request, not only on your page, which is what you want: the sanitise filter has to be in place when options.php runs, so registration cannot wait for your page callback. The hook also fires on admin-post.php and admin-ajax.php, so an AJAX handler that saves one of your options gets the same sanitising.

options.php does the write, and it validates against your registration

Your form posts to options.php, not to your own page. There is no route through your code on save. The endpoint reads $_REQUEST['option_page'], then fires option_page_capability_{$option_page} with manage_options as the default, and refuses the request when the current user fails that capability. Then it builds $allowed_options, which is core's own list with every group from $new_allowed_options merged in, and dies if your group is not in it.

After the nonce check it iterates the option names in $allowed_options[ $option_page ]. For each name it reads $_POST[ $option ], unslashes it, and calls update_option(). Posted keys that are not in that list are never read. That is the whole whitelist mechanism, and it explains both halves of the confusing behaviour: an option you registered saves even if you never drew a field for it, and a field you drew without registering its name is silently dropped.

settings_fields( $option_group ) is what supplies the endpoint with the two values it needs plus the nonce. Given the group name wpsifero_badge, the function echoes exactly this, which I confirmed against the 7.0.7 source by calling it with stub dependencies for the nonce and referer values:

<input type='hidden' name='option_page' value='wpsifero_badge' />
<input type="hidden" name="action" value="update" />
<input type="hidden" id="_wpnonce" name="_wpnonce" value="..." />

The nonce action it creates is "{$option_group}-options", and options.php verifies the same string. That is why calling settings_fields() with the wrong group produces the "Are you sure you want to do this?" screen instead of a save: the nonce and the group have to agree.

On success the endpoint redirects to wp_get_referer() with settings-updated=true appended, which is where that query argument comes from. Errors added through add_settings_error() go into a 30-second transient and are rendered by settings_errors() on the destination page.

A registration with no sanitise callback is an unvalidated write

The sanitize_callback is attached as a filter on sanitize_option_{$option_name}, and update_option() calls sanitize_option() before it compares old and new values. So the callback runs on every path that writes the option, not only on the settings form. Leave it out and the only processing your value receives is WordPress's own type-specific handling, which for a plain string is nothing at all.

I registered two settings, one with a hex-colour callback and one without, then pushed values through the filters the registrations installed. The first column is what the user posted, the second is what landed in the option.

Posted valueWith sanitize_callbackWithout
#A1B2C3#a1b2c3#A1B2C3, stored as typed
red'' (empty string)red
red; DROP TABLE''the whole string
<script>x</script>''stored intact

Every one of those places becomes a site where a single escaping mistake is stored XSS. A sanctioned sanitise callback turns that class of bug into an empty field.

One design point about the callback itself. It receives one argument by default, and it must return a value that is safe to store. Returning an empty string for bad input is common, and it is right when the field has a visible default the reader sees on the next load. To keep the previous value instead, read it back with get_option() and return that. The callback cannot report anything either way; to tell the reader why input was refused, add a settings error inside it and the redirect will surface it.

The render callback still checks capability, and it is not redundancy

add_options_page(), like every menu function, takes a capability and uses it to decide whether the menu entry exists for this user. When the user lacks it, add_submenu_page() returns false and never calls add_action() for your callback, so the normal path to your page is closed.

That is a menu-level filter, not an access check on the page. A page registered with a callback is reachable through admin.php?page=your-slug in ways the menu code does not govern, and other plugins can call the registered callback directly. The documented contract in the source says it plainly, in the docblock for add_menu_page(): "The function which is hooked in to handle the output of the page must check that the user has the required capability as well." Two lines inside the callback are the difference between a page that is hidden and a page that is protected.

function wpsifero_render_badge_page(): void {
    if ( ! current_user_can( 'manage_options' ) ) {
        wp_die( esc_html__( 'You are not allowed to manage these settings.', 'wpsifero' ), 403 );
    }
    ?>
    <div class="wrap">
        <h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
        <form action="options.php" method="post">
            <?php
            settings_fields( 'wpsifero_badge' );
            do_settings_sections( 'wpsifero_badge' );
            submit_button();
            ?>
        </form>
    </div>
    <?php
}

do_settings_sections( $page ) walks the sections registered for that group, prints each heading and section intro, then wraps the fields for that section in <table class="form-table" role="presentation">. The rendered output for the two registrations above, with the option at its default, is short enough to read in full:

<h2>Appearance</h2>
<p>Colour applied to the badge on the front end.</p>
<table class="form-table" role="presentation">
  <tr>
    <th scope="row"><label for="wpsifero_accent">Badge colour</label></th>
    <td><input type="text" id="wpsifero_accent" name="wpsifero_accent" value="#2271b1" class="regular-text" /></td>
  </tr>
</table>
<p class="submit"><input type="submit" name="submit" id="submit" class="button button-primary button-large" value="Save Changes" /></p>

The submit line comes from submit_button() with no arguments, which delegates to get_submit_button(); the class list and the "Save Changes" label are the defaults in that function.

The field markup is yours, which is the point of the API: core owns the storage, the validation order, the nonce and the redirect, and you own the HTML. Notice that the input name has to be the option name exactly. That is the join between the form and $allowed_options, and it is the thing to check first when a save does nothing.

Lab 30 minutes

Build a settings page at Settings → Badge with two fields: a hex colour stored in wpsifero_accent and a checkbox stored in wpsifero_show_badge. Register both with register_setting() on admin_init, give the colour a sanitise callback, and give both show_in_rest.

Then answer four questions by testing them, not by reasoning about them. What is stored when the colour field is submitted empty? What happens on save if you rename the input to wpsifero_accent_colour and leave the registration alone? What does GET /wp/v2/settings return for the checkbox before and after it is set the first time? And what does the page do when you load it as a user with the editor role, both through the menu and by typing the page URL directly?

Show the solution and the reasoning

The registration for both options, with the checkbox's absence handled explicitly:

add_action( 'admin_init', 'wpsifero_register_badge_settings' );

function wpsifero_register_badge_settings(): void {
    register_setting(
        'wpsifero_badge',
        'wpsifero_accent',
        array(
            'type'              => 'string',
            'default'           => '#2271b1',
            'sanitize_callback' => 'wpsifero_sanitize_hex_colour',
            'show_in_rest'      => true,
        )
    );

    register_setting(
        'wpsifero_badge',
        'wpsifero_show_badge',
        array(
            'type'              => 'boolean',
            'default'           => false,
            'sanitize_callback' => 'wpsifero_sanitize_checkbox',
            'show_in_rest'      => true,
        )
    );
}

function wpsifero_sanitize_checkbox( $value ): bool {
    return (bool) $value;
}

The empty field is the interesting one, and it is why the checkbox callback exists at all. An empty text input posts an empty string, the hex callback rejects it, and the empty string is stored. The next page load shows an empty field, which is honest but unhelpful. If you would rather reject the value and keep the previous one, the callback has to read it back itself:

function wpsifero_sanitize_hex_colour( $value ): string {
    if ( ! is_string( $value ) ) {
        return (string) get_option( 'wpsifero_accent', '#2271b1' );
    }

    $value = ltrim( strtolower( trim( $value ) ), '#' );

    return preg_match( '/^[0-9a-f]{6}$/', $value )
        ? '#' . $value
        : (string) get_option( 'wpsifero_accent', '#2271b1' );
}

That version keeps the old colour and silently discards the bad one. Both designs are defensible. Keeping the old value means the setting can never be empty and every consumer can assume a valid colour. Storing the empty string means the reader can tell their input was refused. What you must not do is return false and assume the old value survives: update_option() stores whatever the filter returns.

The renamed input stores nothing and reports a successful save. The posted key wpsifero_accent_colour is not in $allowed_options, so the loop in options.php never looks at it. Meanwhile wpsifero_accent is in the list and missing from $_POST, so $value stays null and update_option( 'wpsifero_accent', null ) runs. With a callback that handles non-string input at the top and returns the stored value, the option survives. With one that returns a string for a null input, an unrelated save wipes it.

A registered boolean with 'default' => false answers false from GET /wp/v2/settings before any save, because the default key installs a default_option_{$option_name} filter and the REST schema takes its default from the arguments array. After a save the response carries the stored true. Drop default from the arguments and the schema reports null for the untouched setting, which most editors handle badly.

The editor sees nothing in the menu, because add_options_page() with manage_options does not register the entry for a role that lacks it. Typing /wp-admin/options-general.php?page=wpsifero-badge also fails, and this is the part worth testing rather than assuming. The callback was never hooked for that user, so WordPress finds no handler for the page and stops with "Sorry, you are not allowed to access this page." Your own check never runs. Change the menu capability to edit_posts and the same URL loads the page, at which point your check is the only thing rejecting the editor. That is the case the check exists for, and the case where a missing check is a privilege escalation.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.