The Settings API
register_setting, add_settings_section and add_settings_field give you nonce, capability check, sanitising and redirect for free.
register_setting( string $option_group, string $option_name, array $args = array() )The Settings API is core's plumbing for admin option forms. You declare an option, some sections and some fields; WordPress renders the table, posts the form to wp-admin/options.php, verifies the nonce, checks the capability, runs your sanitise callback, saves the option and redirects back with a success notice. A hand-rolled form has to reimplement every one of those steps, and the allow-list built by register_setting() is what stops a crafted POST from writing an option you never intended to expose.
The three calls
register_setting( 'acme_group', 'acme_options', array( 'sanitize_callback' => 'acme_sanitize' ) );
add_settings_section( 'acme_main', 'General', 'acme_section_cb', 'acme-settings' );
add_settings_field( 'acme_limit', 'Events per page', 'acme_field_cb', 'acme-settings', 'acme_main' );
All three belong on admin_init. Note the two different identifiers: acme_group is the option group you pass to settings_fields(), while acme-settings is the page slug you pass to do_settings_sections(). Confusing them yields a page that renders nothing, or a save that quietly discards the input.
Shorthand
settings_fields() is exactly the three hidden inputs you would otherwise type by hand:
settings_fields( 'acme_group' );
// ===
echo '<input type="hidden" name="option_page" value="acme_group" />';
echo '<input type="hidden" name="action" value="update" />';
wp_nonce_field( 'acme_group-options' );
The $args array on register_setting() arrived in WordPress 4.7 and accepts type, description, default, show_in_rest, sanitize_callback, and label since 6.6.
Example
<?php
defined( 'ABSPATH' ) || exit;
/**
* Everything is stored in a single array option, so there is one row in
* wp_options and one sanitise callback rather than one of each per field.
*/
function acme_events_register_settings(): void {
register_setting(
'acme_events_group', // $option_group - matches settings_fields()
'acme_events_options', // $option_name - the wp_options row
array(
'type' => 'object',
'description' => __( 'Acme Events display settings.', 'acme-events' ),
'sanitize_callback' => 'acme_events_sanitize_options',
'default' => array(
'per_page' => 10,
'show_past' => false,
'contact' => '',
),
)
);
add_settings_section(
'acme_events_display', // $id
__( 'Display', 'acme-events' ),
static function (): void {
echo '<p>' . esc_html__( 'How event lists appear on the front end.', 'acme-events' ) . '</p>';
},
'acme-events-settings' // $page - matches do_settings_sections()
);
add_settings_field(
'acme_events_per_page',
__( 'Events per page', 'acme-events' ),
'acme_events_field_per_page',
'acme-events-settings', // $page
'acme_events_display', // $section
// 'label_for' wraps the field title in a <label for="...">.
array( 'label_for' => 'acme_events_per_page' )
);
}
add_action( 'admin_init', 'acme_events_register_settings' );
// Field callbacks ECHO their markup - unlike shortcode callbacks, which return it.
function acme_events_field_per_page( array $args ): void {
$options = get_option( 'acme_events_options' );
printf(
'<input type="number" min="1" max="50" id="%1$s" name="acme_events_options[per_page]" value="%2$s" class="small-text" />',
esc_attr( $args['label_for'] ),
esc_attr( $options['per_page'] ?? 10 )
);
}
function acme_events_sanitize_options( $input ): array {
$clean = array();
$clean['per_page'] = min( 50, max( 1, absint( $input['per_page'] ?? 10 ) ) );
$clean['show_past'] = ! empty( $input['show_past'] );
$clean['contact'] = sanitize_email( $input['contact'] ?? '' );
return $clean;
}
// The page itself.
function acme_events_settings_page(): void {
// The capability on add_options_page() controls the menu; check it again here.
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
<div class="wrap">
<h1> echo esc_html( get_admin_page_title() ); </h1>
<form action="options.php" method="post">
settings_fields( 'acme_events_group' ); // option group
do_settings_sections( 'acme-events-settings' ); // page slug
submit_button();
</form>
</div>
}
add_action( 'admin_menu', static function (): void {
add_options_page(
__( 'Acme Events Settings', 'acme-events' ),
__( 'Acme Events', 'acme-events' ),
'manage_options',
'acme-events-settings',
'acme_events_settings_page'
);
} );When to use it
- Adding a settings screen for a plugin without writing nonce, capability and redirect handling yourself.
- Validating an admin-entered value and showing the user why it was rejected.
- Making a plugin option readable by the block editor through /wp-json/wp/v2/settings.
- Grouping several related options into one wp_options row with a single sanitise pass.
More examples
Validation feedback with add_settings_error
The sanitise callback is the only place validation belongs, because it is the last thing to run before the value is stored. Returning the previous value instead of a default on failure means a typo in one field does not silently reset the others.
<?php
defined( 'ABSPATH' ) || exit;
/**
* Errors registered inside the sanitise callback are shown by core on the
* redirect back to the settings page.
*
* add_settings_error( string $setting, string $code, string $message, string $type = 'error' )
* 'warning' and 'info' became valid $type values in WordPress 5.3.
*/
function acme_events_sanitize_options( $input ): array {
// Start from the stored value so a rejected field keeps its old value
// rather than reverting to the default.
$clean = (array) get_option( 'acme_events_options', array() );
$per_page = absint( $input['per_page'] ?? 0 );
if ( $per_page < 1 || $per_page > 50 ) {
add_settings_error(
'acme_events_options',
'acme_events_per_page_range',
__( 'Events per page must be between 1 and 50. The previous value was kept.', 'acme-events' ),
'error'
);
} else {
$clean['per_page'] = $per_page;
}
$contact = sanitize_email( $input['contact'] ?? '' );
if ( '' !== $input['contact'] && '' === $contact ) {
add_settings_error(
'acme_events_options',
'acme_events_contact_invalid',
__( 'That contact address is not a valid email.', 'acme-events' ),
'warning'
);
} else {
$clean['contact'] = $contact;
}
return $clean;
}
// On a page that is not a core Settings screen, print the notices yourself.
// settings_errors( string $setting = '', bool $sanitize = false, bool $hide_on_update = false )
add_action( 'admin_notices', static function (): void {
settings_errors( 'acme_events_options' );
} );Section and field arguments
label_for is worth setting on every field: without it the title cell is plain text and clicking it does not focus the input, which fails a basic accessibility check. The class argument gives you a hook for showing or hiding a whole row from JavaScript.
<?php
defined( 'ABSPATH' ) || exit;
add_action( 'admin_init', static function (): void {
// $args on add_settings_section() was added in WordPress 6.1.
// %s in before_section is replaced with the section_class value.
add_settings_section(
'acme_events_advanced',
__( 'Advanced', 'acme-events' ),
'__return_null', // No intro text; core still needs a callable.
'acme-events-settings',
array(
'before_section' => '<div class="%s">',
'after_section' => '</div>',
'section_class' => 'acme-advanced-section',
)
);
add_settings_field(
'acme_events_cache_ttl',
__( 'Cache lifetime', 'acme-events' ),
'acme_events_field_cache_ttl',
'acme-events-settings',
'acme_events_advanced',
array(
// Wraps the title in <label for="acme_events_cache_ttl">.
'label_for' => 'acme_events_cache_ttl',
// Added in WordPress 4.2: a class on the <tr>, useful for JS toggling.
'class' => 'acme-row acme-row--advanced',
)
);
} );Exposing a setting to the REST API
Needed the moment a block, a sidebar panel or a headless client has to read the setting. An object type must carry an explicit schema — without one, core cannot serialise the value and the option is omitted from the settings endpoint entirely.
<?php
defined( 'ABSPATH' ) || exit;
add_action( 'init', static function (): void {
// show_in_rest must be registered on 'init' as well as 'admin_init',
// because REST requests never run admin_init.
register_setting(
'acme_events_group',
'acme_events_options',
array(
'type' => 'object',
'default' => array( 'per_page' => 10, 'show_past' => false ),
'sanitize_callback' => 'acme_events_sanitize_options',
'show_in_rest' => array(
'schema' => array(
'type' => 'object',
'properties' => array(
'per_page' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 50 ),
'show_past' => array( 'type' => 'boolean' ),
),
'additionalProperties' => false,
),
),
)
);
} );
// The value is now readable and writable at:
// GET /wp-json/wp/v2/settings
// POST /wp-json/wp/v2/settings { "acme_events_options": { "per_page": 20 } }
// Both require a user with manage_options.
Discussion