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
| Function | Registers | Hook |
|---|---|---|
register_setting( $group, $option, $args ) | An option, its type, default and sanitise callback | init |
add_settings_section( $id, $title, $callback, $page ) | A titled group of fields on a page | admin_init |
add_settings_field( $id, $title, $callback, $page, $section, $args ) | One row in a section | admin_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
// 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
// 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 (errorby default, orsuccess,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
// 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
// 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
// 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:
settings_fields( 'myplugin' )printed hidden fields: the option group, the action and a nonce.- The form posts to
wp-admin/options.php. It checks the nonce and the capability, confirms thatmyplugin_settingsbelongs to themyplugingroup, and callsupdate_option(), which runs your sanitise callback. - Errors from
add_settings_error()are stored briefly, and the user is redirected back withsettings-updated=true. 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
// 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():
$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()oninitlinks 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_formakes rows accessible and extra arguments let one callback render many fields. - The form posts to
options.phpwithsettings_fields()anddo_settings_sections(); pages outside the Settings menu callsettings_errors()themselves. - Change who may save with
option_page_capability_{$group}.