Skip to content
wp-skills
Register
tools/settings-options-page-generator

Settings / Options Page Generator

Build a WordPress settings page with the Settings API: a menu entry anywhere in wp-admin, as many fields as you need, and one option holding them all — with the option group named once, so the form cannot post to a group register_setting() never allowed.

The heading on the page, and the browser tab.

Every option but the last wraps add_submenu_page(); the last is add_menu_page().

A dashicons- class, a URL, or 'none'.

Blank puts it at the bottom. 80 is Settings.

The page query argument in the URL: wp-admin/options-general.php?page=this.

Who can open the page. manage_options is the administrator-only one settings pages normally use.

One row in wp_options holds every field. The option group is named after it, so the form and register_setting() cannot drift apart.

Only pages under Settings get these from core. Anywhere else the page has to print them itself.

Adds the schema /wp/v2/settings needs. Without it an object option is exposed as null.

A one-line way to read a single setting from a template, defaults included.

Fields1
  • #1

    Lowercase letters, numbers and underscores. This is the key inside the stored option.

class-wp-skills-settings-my-plugin-settings.php
<?php
/**
 * Settings page: My Plugin Settings
 *
 * Generated by wp-skills.com
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

class WP_Skills_Settings_My_Plugin_Settings {

	/**
	 * The option row every field is stored in, and the group the form posts
	 * under.
	 *
	 * Both are constants for the same reason the meta box generator's nonce is:
	 * `register_setting()` records the group in an allow-list that
	 * wp-admin/options.php checks against the `option_page` field
	 * `settings_fields()` writes, and a mismatch is not an error you can see.
	 * The page renders, the form submits, and options.php stops on
	 * "The <code>...</code> options page is not in the allowed options list."
	 * with nothing saved. Naming the group once removes the drift.
	 */
	const OPTION_NAME  = 'my_plugin_settings';
	const OPTION_GROUP = 'my_plugin_settings_group';
	const MENU_SLUG    = 'my-plugin-settings';
	const CAPABILITY   = 'manage_options';

	private static $fields = array(
		array(
			'id'          => 'api_key',
			'label'       => 'API key',
			'type'        => 'text',
			'description' => 'Kept in the options table, so treat it as readable by any administrator.',
			'placeholder' => '',
			'default'     => '',
		)
	);

	public function __construct() {
		add_action( 'admin_menu', array( $this, 'add_page' ) );
		add_action( 'admin_init', array( $this, 'register_settings' ) );
	}

	public function add_page() {
		$hook_suffix = add_options_page(
			__( 'My Plugin Settings', 'text-domain' ),
			__( 'My Plugin', 'text-domain' ),
			self::CAPABILITY,
			self::MENU_SLUG,
			array( $this, 'render_page' )
		);

		/*
		 * add_options_page() wraps add_submenu_page(), which returns false when
		 * the current user lacks the capability. Anything built from the hook
		 * name belongs below this guard: add_action( 'load-' . $hook_suffix, … )
		 * on a false hook registers a callback on a hook named 'load-'.
		 */
		if ( ! $hook_suffix ) {
			return;
		}

		// Anything that needs this screen and only this screen goes here — a
		// Help tab, a stylesheet, a per-page notice:
		// add_action( 'load-' . $hook_suffix, array( $this, 'on_load' ) );
	}

	/**
	 * One option, holding every field, with one sanitizer.
	 *
	 * The alternative — an option per field — means a register_setting() call
	 * and a database row each, and a sanitize callback that cannot see the
	 * other values.
	 */
	public function register_settings() {
		register_setting(
			self::OPTION_GROUP,
			self::OPTION_NAME,
			array(
				'type'              => 'object',
				'default'           => self::defaults(),
				'sanitize_callback' => array( $this, 'sanitize' ),
				'show_in_rest'      => false,
			)
		);

		add_settings_section(
			self::MENU_SLUG . '_section',
			__( 'General', 'text-domain' ),
			array( $this, 'render_section' ),
			self::MENU_SLUG
		);

		foreach ( self::$fields as $field ) {
			/*
			 * 'label_for' is what turns the field title into a real
			 * <label for="...">; without it do_settings_fields() prints the
			 * title as bare text in the <th> and clicking it does nothing.
			 * A radio group has no single input to point at, so it is the one
			 * type registered without it.
			 */
			$args = 'radio' === $field['type']
				? array()
				: array( 'label_for' => $field['id'] );

			add_settings_field(
				$field['id'],
				esc_html( $field['label'] ),
				array( $this, 'render_field' ),
				self::MENU_SLUG,
				self::MENU_SLUG . '_section',
				array_merge( $args, array( 'field' => $field ) )
			);
		}
	}

	public function render_section() {
		printf(
			'<p>%s</p>',
			esc_html__( 'Lorem ipsum dolor sit amet.', 'text-domain' )
		);
	}

	public function render_field( $args ) {
		$field    = $args['field'];
		$settings = self::get_settings();
		$value    = isset( $settings[ $field['id'] ] ) ? $settings[ $field['id'] ] : '';

		// Every field posts inside one array, so options.php hands the whole
		// set to one sanitize callback.
		$name = self::OPTION_NAME . '[' . $field['id'] . ']';

		switch ( $field['type'] ) {
			default:
				printf(
					'<input class="regular-text" id="%1$s" name="%2$s" type="%3$s" value="%4$s" placeholder="%5$s">',
					esc_attr( $field['id'] ),
					esc_attr( $name ),
					esc_attr( $field['type'] ),
					esc_attr( $value ),
					esc_attr( $field['placeholder'] )
				);
		}

		if ( '' !== $field['description'] ) {
			printf( '<p class="description">%s</p>', esc_html( $field['description'] ) );
		}
	}

	public function render_page() {
		// The capability given to add_options_page() controls the menu entry and
		// core's own check on the request. This repeats it so the callback is
		// still safe if anything ever calls it directly.
		if ( ! current_user_can( self::CAPABILITY ) ) {
			wp_die( esc_html__( 'You do not have permission to access this page.', 'text-domain' ) );
		}

		/*
		 * Nothing to do: this page hangs off Settings, and admin-header.php
		 * includes options-head.php — which calls settings_errors() — for every
		 * screen whose $parent_file is options-general.php. Calling it again
		 * here prints every notice twice.
		 */
		?>
		<div class="wrap">
			<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>

			<form action="options.php" method="post">
				<?php
				// The same constant register_setting() was given. This pair is
				// what options.php checks before it saves anything.
				settings_fields( self::OPTION_GROUP );
				do_settings_sections( self::MENU_SLUG );
				submit_button();
				?>
			</form>
		</div>
		<?php
	}

	/**
	 * Every field's default, keyed the way the option stores them.
	 */
	public static function defaults() {
		$defaults = array();

		foreach ( self::$fields as $field ) {
			$defaults[ $field['id'] ] = $field['default'];
		}

		return $defaults;
	}

	/**
	 * The stored settings, with any key that has never been saved filled in.
	 *
	 * get_option() is called with one argument on purpose. Passing a fallback
	 * makes core skip the default registered with register_setting() — the
	 * `default_option_{$option}` filter returns the caller's value whenever one
	 * was passed — so get_option( self::OPTION_NAME, array() ) would quietly
	 * throw the registered defaults away. The merge below is what fills in a
	 * key added to this file after the option was first saved.
	 */
	public static function get_settings() {
		$stored = get_option( self::OPTION_NAME );

		return array_merge( self::defaults(), is_array( $stored ) ? $stored : array() );
	}

	/**
	 * The only place a value written to this option is constrained.
	 *
	 * Three things this has to get right, each of them measured against
	 * WordPress rather than assumed:
	 *
	 * 1. It runs twice on the first save. update_option() sanitizes, finds no
	 *    row to update, and hands off to add_option(), which runs the same
	 *    filter over the already-sanitized value. Anything that is not
	 *    idempotent — appending, escaping, incrementing — corrupts the first
	 *    save and behaves perfectly on every save after it.
	 *
	 * 2. Returning null does not mean "leave the option alone". It writes NULL
	 *    to the row, get_option() then returns an empty string, and every
	 *    setting on the page is gone. sanitize_hex_color() returns null for
	 *    anything that is not a hex colour, which is why the colour case casts.
	 *
	 * 3. wp-admin/options.php passes null itself when the form posts nothing
	 *    under this option name — which is exactly what a page of nothing but
	 *    unchecked checkboxes posts. Hence the is_array() guard rather than a
	 *    trusting foreach.
	 */
	public function sanitize( $value ) {
		$posted = is_array( $value ) ? $value : array();
		$stored = get_option( self::OPTION_NAME );
		$stored = is_array( $stored ) ? $stored : array();
		$clean  = array();

		foreach ( self::$fields as $field ) {
			$key = $field['id'];

			// An unchecked box posts nothing at all, so its absence is the
			// value. Falling through to the "not submitted" branch below would
			// leave the previous '1' in place forever.
			if ( 'checkbox' === $field['type'] ) {
				$clean[ $key ] = empty( $posted[ $key ] ) ? '0' : '1';
				continue;
			}

			if ( ! isset( $posted[ $key ] ) ) {
				// Not on this form: keep what is stored rather than blanking a
				// value the user never saw.
				$clean[ $key ] = isset( $stored[ $key ] ) ? $stored[ $key ] : $field['default'];
				continue;
			}

			$clean[ $key ] = $this->sanitize_field( $field, $posted[ $key ] );
		}

		return $clean;
	}

	private function sanitize_field( $field, $value ) {
		// options.php has already run wp_unslash() over the whole $_POST array,
		// so the value arrives without the slashes WordPress adds.
		switch ( $field['type'] ) {
			default:
				return sanitize_text_field( $value );
		}
	}
}

new WP_Skills_Settings_My_Plugin_Settings();

/**
 * One setting, by key, from anywhere in the theme or plugin.
 *
 * Reads through the same defaults the page registers, so a key that has never
 * been saved returns what the form shows rather than an empty string.
 */
function my_plugin_settings_get( $key, $fallback = '' ) {
	$settings = WP_Skills_Settings_My_Plugin_Settings::get_settings();

	return isset( $settings[ $key ] ) && '' !== $settings[ $key ] ? $settings[ $key ] : $fallback;
}