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.
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;
}