Settings Page Generator

Generate a WordPress settings page with the Settings API: menu entry, one registered option, the fields, and a matching sanitize callback.

Live output

Enable JavaScript to customise; default output below.

Shown in the browser tab and as the heading on the page.

Shown in the admin sidebar, so keep it short.

Ends up in the page URL. Also the section ID the fields are attached to.

Checked twice: once for the menu entry, once inside the page. The menu check alone is not access control.

Where it lives

One plugin, one settings page belongs under Settings. A top-level menu is for plugins with several screens.

More options Show

Used for a top-level menu only.

Used for a top-level menu only. 80 puts it near Settings. Two plugins on the same number fight over it.

Live preview settings-page.php
<?php
/**
 * The "My Plugin Settings" settings page.
 */

/**
 * Adds the menu entry.
 */
function my_plugin_menu() {
	add_options_page(
		__( 'My Plugin Settings', 'my-plugin' ),
		__( 'My Plugin', 'my-plugin' ),
		'manage_options',
		'my-plugin-settings',
		'my_plugin_render_page'
	);
}
add_action( 'admin_menu', 'my_plugin_menu' );

/**
 * The stored defaults, used before anything is saved.
 *
 * @return array
 */
function my_plugin_defaults() {
	return array(
		'api_key' => '',
		'enabled' => '',
	);
}

/**
 * Registers the option, the section and every field.
 */
function my_plugin_register_settings() {
	register_setting(
		'my_plugin_group',
		'my_plugin_settings',
		array(
			'type'              => 'array',
			'sanitize_callback' => 'my_plugin_sanitize',
			'default'           => my_plugin_defaults(),
			'show_in_rest'      => false,
		)
	);

	add_settings_section(
		'my_plugin_section',
		__( 'General', 'my-plugin' ),
		'my_plugin_section_intro',
		'my-plugin-settings'
	);

	add_settings_field(
		'api_key',
		__( 'API key', 'my-plugin' ),
		'my_plugin_render_field',
		'my-plugin-settings',
		'my_plugin_section',
		array(
			'key'         => 'api_key',
			'type'        => 'text',
			'label'       => __( 'API key', 'my-plugin' ),
			'description' => __( 'Found in your account under Developer.', 'my-plugin' ),
			'choices'     => array(),
			'label_for'   => 'api_key',
		)
	);

	add_settings_field(
		'enabled',
		__( 'Enable the sync', 'my-plugin' ),
		'my_plugin_render_field',
		'my-plugin-settings',
		'my_plugin_section',
		array(
			'key'         => 'enabled',
			'type'        => 'checkbox',
			'label'       => __( 'Enable the sync', 'my-plugin' ),
			'description' => '',
			'choices'     => array(),
			'label_for'   => 'enabled',
		)
	);
}
add_action( 'admin_init', 'my_plugin_register_settings' );

/**
 * Prints the text under the section heading.
 */
function my_plugin_section_intro() {
	// Nothing to say here yet.
}

/**
 * Renders one field.
 *
 * @param array $args Arguments passed by add_settings_field().
 */
function my_plugin_render_field( $args ) {
	$options = get_option( 'my_plugin_settings', my_plugin_defaults() );
	$key     = $args['key'];
	$value   = isset( $options[ $key ] ) ? $options[ $key ] : '';
	$name    = 'my_plugin_settings[' . $key . ']';

	switch ( $args['type'] ) {
		case 'textarea':
			printf(
				'<textarea class="large-text" rows="5" name="%1$s" id="%2$s">%3$s</textarea>',
				esc_attr( $name ),
				esc_attr( $key ),
				esc_textarea( $value )
			);
			break;

		case 'checkbox':
			printf(
				'<label><input type="checkbox" name="%1$s" id="%2$s" value="1"%3$s /> %4$s</label>',
				esc_attr( $name ),
				esc_attr( $key ),
				checked( $value, '1', false ),
				esc_html( $args['label'] )
			);
			break;

		case 'number':
			printf(
				'<input type="number" class="small-text" name="%1$s" id="%2$s" value="%3$s" />',
				esc_attr( $name ),
				esc_attr( $key ),
				esc_attr( $value )
			);
			break;

		case 'select':
			echo '<select name="' . esc_attr( $name ) . '" id="' . esc_attr( $key ) . '">';

			foreach ( $args['choices'] as $choice ) {
				printf(
					'<option value="%1$s"%2$s>%1$s</option>',
					esc_attr( $choice ),
					selected( $value, $choice, false )
				);
			}

			echo '</select>';
			break;

		default:
			printf(
				'<input type="text" class="regular-text" name="%1$s" id="%2$s" value="%3$s" />',
				esc_attr( $name ),
				esc_attr( $key ),
				esc_attr( $value )
			);
	}

	if ( '' !== $args['description'] ) {
		echo '<p class="description">' . esc_html( $args['description'] ) . '</p>';
	}
}

/**
 * Cleans the submitted values. Anything not declared here is dropped.
 *
 * @param mixed $input Raw values from the form.
 * @return array
 */
function my_plugin_sanitize( $input ) {
	$clean = my_plugin_defaults();

	if ( ! is_array( $input ) ) {
		return $clean;
	}

	$clean['api_key'] = isset( $input['api_key'] )
		? sanitize_text_field( $input['api_key'] )
		: '';

	$clean['enabled'] = empty( $input['enabled'] ) ? '' : '1';

	return $clean;
}

/**
 * Prints the page itself.
 */
function my_plugin_render_page() {
	// The menu capability hides the link. This is what actually stops the request.
	if ( ! current_user_can( 'manage_options' ) ) {
		wp_die( esc_html__( 'You are not allowed to change these settings.', 'my-plugin' ) );
	}

	echo '<div class="wrap">';
	echo '<h1>' . esc_html( get_admin_page_title() ) . '</h1>';
	echo '<form action="options.php" method="post">';

	settings_fields( 'my_plugin_group' );
	do_settings_sections( 'my-plugin-settings' );
	submit_button();

	echo '</form>';
	echo '</div>';
}

Output is valid and updates as you type.

Describe the page and its fields, and the generator writes the Settings API wiring: the menu entry, one registered option, a row per field, and a sanitize callback that matches the types you chose.

How to use

  1. Keep one option holding an array. A dozen separate options is a dozen autoloaded rows on every request for one screen nobody visits.
  2. Set the capability once. It is checked twice in the output: for the menu entry, and again inside the page, because hiding a link is not access control.
  3. Add fields with lowercase keys. The key becomes the array key, the input name and the default, so changing it later orphans the saved value.
  4. Give select fields a comma separated choice list. The sanitize callback rejects anything that is not in it, which is the point of a select.
  5. Paste the file into your plugin and load it. Nothing else has to be registered.

Example

Two fields, saved under one option, with the sanitize callback that goes with them:

function acme_sanitize( $input ) {
	$clean = acme_defaults();

	if ( ! is_array( $input ) ) {
		return $clean;
	}

	$clean['api_key'] = isset( $input['api_key'] )
		? sanitize_text_field( $input['api_key'] )
		: '';

	$clean['enabled'] = empty( $input['enabled'] ) ? '' : '1';

	return $clean;
}

Starting from the defaults rather than from $input is what makes the callback safe: a key you never declared cannot survive a save.

Pitfalls

  • add_options_page() hides the menu link for users without the capability. It does not block the URL, so the page itself has to check as well.
  • The sanitize callback runs on save through options.php only. Code calling update_option() directly bypasses it entirely.
  • An unchecked checkbox sends nothing at all. Reading $input['enabled'] without isset() is a notice, and treating a missing key as unchanged means the box can never be turned off.
  • register_setting() and add_settings_field() belong on admin_init, not admin_menu. On the wrong hook the form saves nothing and gives no error.
  • The section ID passed to do_settings_sections() must match the page slug used in add_settings_field(), or the fields are registered and never printed.
  • Autoloaded options are loaded on every request, front end included. One array is cheap; forty separate options are not.
  • show_in_rest makes the whole option readable through the REST API. An API key does not belong in it.
  • Two plugins asking for the same top-level menu position push each other around. Submenus avoid the argument.

Compatibility

The Settings API (register_setting(), add_settings_section(), add_settings_field(), settings_fields()) has been stable since WordPress 2.7, and the default and show_in_rest arguments to register_setting() need WordPress 4.7 or later. The generated code targets PHP 7.0 and up, and the tool runs entirely in your browser.

Frequently asked questions

Why does my setting not save?
Usually the registration is on the wrong hook, or the group passed to settings_fields() does not match the one in register_setting(). Both have to be the same string.
One option or one per field?
One array. It is a single autoloaded row, a single sanitize callback, and a single thing to delete on uninstall.
How do I add a second section?
Call add_settings_section() again with a new ID and pass that ID as the section argument of the fields that belong to it.
Can I use this with the block editor?
Turn on show_in_rest so the option is readable from JavaScript. Check what is in the option first, since the route exposes all of it.
What should I delete on uninstall?
The one option this page registers. Add delete_option() to uninstall.php, not to the deactivation hook.

From the people who built this tool

WP Adminify

The WordPress admin, rebuilt: a dashboard worth looking at, menu and column control, a real file manager and the login page your client sees.

See WP Adminify Free version on WordPress.org

Weekly drops

New tools, when there are new tools

One email when something worth using ships. No schedule to fill, so no filler.

Your address goes nowhere else, and one click unsubscribes.