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 value | With sanitize_callback | Without |
|---|---|---|
#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.
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.