The Settings API

register_setting, add_settings_section and add_settings_field give you nonce, capability check, sanitising and redirect for free.

Syntaxregister_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

Example · php
<?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><?php echo esc_html( get_admin_page_title() ); ?></h1>
		<form action="options.php" method="post">
			<?php
			settings_fields( 'acme_events_group' );          // option group
			do_settings_sections( 'acme-events-settings' );   // page slug
			submit_button();
			?>
		</form>
	</div>
	<?php
}

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.

Example · php
<?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.

Example · php
<?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.

Example · php
<?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

  • Be the first to comment on this lesson.