The Settings API

Register a settings option with a sanitise callback, build its screen from sections and fields, and report validation errors.

  • Intermediate
  • 10 min read
  • Updated 17 Oct 2026

What you will learn

  • Register settings with sanitisation
  • Render settings fields
  • Show validation errors with add_settings_error
  • Save settings from a page outside the Settings menu

Before you start

The WordPress course showed register_setting() with a sanitise callback. This lesson builds a complete settings screen on top of it: sections and fields that render themselves, a form that posts to options.php, and error messages when a value is rejected. The Settings API handles the nonce, the capability check and the save, so your code is only the parts that are specific to the plugin.

The screen manages the myplugin_settings option you created on activation, with four keys: events per page, currency, maximum guests per booking, and whether to delete data on uninstall.

The three pieces

FunctionRegistersHook
register_setting( $group, $option, $args )An option, its type, default and sanitise callbackinit
add_settings_section( $id, $title, $callback, $page )A titled group of fields on a pageadmin_init
add_settings_field( $id, $title, $callback, $page, $section, $args )One row in a sectionadmin_init

Two names connect them and are easy to mix up:

  • The option group (myplugin) links the form to the settings it may save. settings_fields( 'myplugin' ) prints it into the form.
  • The page (myplugin-settings) links sections and fields to a screen. do_settings_sections( 'myplugin-settings' ) prints them.

Registering the setting

Register the setting on init, not admin_init. admin_init does not run on REST API requests, and registering on init lets you expose the setting to the REST API later without moving code:

PHP
<?php
// wp-content/plugins/myplugin/includes/settings.php
defined( 'ABSPATH' ) || exit;

add_action( 'init', 'myplugin_register_setting' );

function myplugin_register_setting(): void {
    register_setting(
        'myplugin',
        'myplugin_settings',
        array(
            'type'              => 'object',
            'default'           => myplugin_default_settings(),
            'sanitize_callback' => 'myplugin_sanitize_settings',
        )
    );
}

This file lives in includes/, so load it from the main file outside the is_admin() block. myplugin_default_settings() is the helper from the activation lesson.

The sanitise callback

WordPress calls the callback with the submitted value every time the option is saved, by your form, update_option(), WP-CLI or the REST API. It must return the value to store.

PHP
<?php
// wp-content/plugins/myplugin/includes/settings.php

/**
 * Clean and validate submitted settings.
 *
 * @param mixed $input Raw value from the form or update_option().
 * @return array Settings to store.
 */
function myplugin_sanitize_settings( $input ): array {
    $input = is_array( $input ) ? $input : array();
    $clean = myplugin_get_settings(); // Start from current values.

    // Events per page: 1 to 50.
    $per_page = absint( $input['events_per_page'] ?? 0 );

    if ( $per_page >= 1 && $per_page <= 50 ) {
        $clean['events_per_page'] = $per_page;
    } else {
        add_settings_error(
            'myplugin_settings',
            'myplugin_events_per_page',
            __( 'Events per page must be between 1 and 50. The previous value was kept.', 'myplugin' )
        );
    }

    // Currency: allowlist.
    $currency = strtoupper( sanitize_key( $input['currency'] ?? '' ) );

    if ( array_key_exists( $currency, myplugin_currencies() ) ) {
        $clean['currency'] = $currency;
    }

    // Maximum guests: 0 to 20.
    $clean['max_guests'] = min( 20, absint( $input['max_guests'] ?? $clean['max_guests'] ) );

    // Checkbox: missing from the form when unticked.
    $clean['delete_data'] = ! empty( $input['delete_data'] );

    return $clean;
}

function myplugin_currencies(): array {
    return array(
        'GBP' => __( 'Pound sterling', 'myplugin' ),
        'EUR' => __( 'Euro', 'myplugin' ),
        'USD' => __( 'US dollar', 'myplugin' ),
    );
}

Design decisions worth copying:

  • Start from the current settings, not from an empty array. A key the form did not send keeps its value instead of disappearing.
  • Reject invalid values with a message when the user should know, and keep the old value. add_settings_error() takes the setting name, a unique code, the message and an optional type (error by default, or success, warning, info).
  • Allowlist values from a fixed set, such as currency codes.
  • Checkboxes are not sent at all when unticked, so test with empty().

When the option does not exist yet, update_option() hands over to add_option(), and both run the sanitise callback. On that first save your callback can run twice, and an error message can appear twice. Creating the option on activation, as the plugin already does, avoids it.

Sections and fields

Sections and fields only matter on the settings screen, so they go in admin/settings.php on admin_init:

PHP
<?php
// wp-content/plugins/myplugin/admin/settings.php
defined( 'ABSPATH' ) || exit;

add_action( 'admin_init', 'myplugin_settings_fields' );

function myplugin_settings_fields(): void {
    $page = 'myplugin-settings';

    add_settings_section(
        'myplugin_display',
        __( 'Display', 'myplugin' ),
        static function () {
            echo '<p>' . esc_html__( 'How events appear on the site.', 'myplugin' ) . '</p>';
        },
        $page
    );

    add_settings_field(
        'myplugin_events_per_page',
        __( 'Events per page', 'myplugin' ),
        'myplugin_field_number',
        $page,
        'myplugin_display',
        array(
            'label_for' => 'myplugin_events_per_page',
            'key'       => 'events_per_page',
            'min'       => 1,
            'max'       => 50,
        )
    );

    add_settings_field(
        'myplugin_currency',
        __( 'Currency', 'myplugin' ),
        'myplugin_field_currency',
        $page,
        'myplugin_display',
        array( 'label_for' => 'myplugin_currency' )
    );

    add_settings_section( 'myplugin_bookings', __( 'Bookings', 'myplugin' ), '__return_null', $page );

    add_settings_field(
        'myplugin_max_guests',
        __( 'Maximum extra guests', 'myplugin' ),
        'myplugin_field_number',
        $page,
        'myplugin_bookings',
        array(
            'label_for' => 'myplugin_max_guests',
            'key'       => 'max_guests',
            'min'       => 0,
            'max'       => 20,
        )
    );

    add_settings_section( 'myplugin_data', __( 'Data', 'myplugin' ), '__return_null', $page );

    add_settings_field( 'myplugin_delete_data', __( 'Uninstall', 'myplugin' ), 'myplugin_field_delete_data', $page, 'myplugin_data' );
}

The last argument of add_settings_field() is passed to the field callback. Two keys are special: label_for wraps the row title in a <label for="...">, which makes the field accessible, and class adds a CSS class to the row. Any other keys, such as key, min and max here, are yours. That lets one callback render several fields.

Field callbacks

Field names use array syntax, myplugin_settings[events_per_page], so the whole form arrives as one array in $input:

PHP
<?php
// wp-content/plugins/myplugin/admin/settings.php

function myplugin_field_number( array $args ): void {
    $settings = myplugin_get_settings();
    $key      = $args['key'];
    printf(
        '<input type="number" id="%1$s" name="myplugin_settings[%2$s]" value="%3$s" min="%4$d" max="%5$d" class="small-text">',
        esc_attr( $args['label_for'] ),
        esc_attr( $key ),
        esc_attr( (string) $settings[ $key ] ),
        (int) $args['min'],
        (int) $args['max']
    );
}

function myplugin_field_currency( array $args ): void {
    $current = myplugin_get_settings()['currency'];

    echo '<select id="' . esc_attr( $args['label_for'] ) . '" name="myplugin_settings[currency]">';

    foreach ( myplugin_currencies() as $code => $label ) {
        printf( '<option value="%s" %s>%s</option>', esc_attr( $code ), selected( $current, $code, false ), esc_html( $label ) );
    }

    echo '</select>';
}

function myplugin_field_delete_data(): void {
    $checked = myplugin_get_settings()['delete_data'];
    ?>
    <label>
        <input type="checkbox" name="myplugin_settings[delete_data]" value="1" <?php checked( $checked ); ?>>
        <?php esc_html_e( 'Delete all events and bookings when the plugin is deleted', 'myplugin' ); ?>
    </label>
    <p class="description"><?php esc_html_e( 'This cannot be undone. Leave unticked if you might reinstall the plugin.', 'myplugin' ); ?></p>
    <?php
}

selected() and checked() print the attribute when the two values match. Pass false as the third argument to return instead of print, as in the printf() call. The min and max attributes help the user, but they are not security: anyone can submit any value, which is why the sanitise callback checks the range again.

The page

Replace the placeholder from the previous lesson:

PHP
<?php
// wp-content/plugins/myplugin/admin/settings.php

function myplugin_render_settings_page(): void {
    if ( ! current_user_can( 'manage_options' ) ) {
        return;
    }

    // Pages outside the Settings menu must show messages themselves.
    if ( isset( $_GET['settings-updated'] ) && ! get_settings_errors( 'myplugin_settings' ) ) {
        add_settings_error( 'myplugin_settings', 'myplugin_saved', __( 'Settings saved.', 'myplugin' ), 'success' );
    }
    ?>
    <div class="wrap">
        <h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
        <?php settings_errors( 'myplugin_settings' ); ?>

        <form action="options.php" method="post">
            <?php
            settings_fields( 'myplugin' );
            do_settings_sections( 'myplugin-settings' );
            submit_button();
            ?>
        </form>
    </div>
    <?php
}

What happens on save:

  1. settings_fields( 'myplugin' ) printed hidden fields: the option group, the action and a nonce.
  2. The form posts to wp-admin/options.php. It checks the nonce and the capability, confirms that myplugin_settings belongs to the myplugin group, and calls update_option(), which runs your sanitise callback.
  3. Errors from add_settings_error() are stored briefly, and the user is redirected back with settings-updated=true.
  4. settings_errors() displays them.

Pages under the Settings menu show these messages automatically. This page lives under Events, so it calls settings_errors() itself and adds the "Settings saved." message when there were no errors.

Changing the required capability

options.php requires manage_options by default, whatever capability your menu uses. If a different role should manage these settings, change both the menu capability and the save capability with the option_page_capability_{$group} filter:

PHP
<?php
// wp-content/plugins/myplugin/includes/settings.php
add_filter(
    'option_page_capability_myplugin',
    static function () {
        return 'myplugin_manage_bookings';
    }
);

Without the filter, a booking manager could open the page but every save would fail with an error.

Reading settings elsewhere

Everywhere else in the plugin, read settings through the helper, never with a bare get_option():

PHP
$per_page = myplugin_get_settings()['events_per_page'];

The helper fills in defaults for keys added in later versions, so older sites keep working. The option is small and used on most front-end requests, so leaving it autoloaded is correct.

Summary

  • register_setting() on init links an option to a group and a sanitise callback that runs on every save, from any source.
  • Sanitise callbacks start from current values, allowlist choices, check ranges, treat missing checkboxes as off, and report problems with add_settings_error().
  • Sections and fields are registered on admin_init; label_for makes rows accessible and extra arguments let one callback render many fields.
  • The form posts to options.php with settings_fields() and do_settings_sections(); pages outside the Settings menu call settings_errors() themselves.
  • Change who may save with option_page_capability_{$group}.

Further reading