Settings Page Generator
Generate a WordPress settings page with the Settings API: menu entry, one registered option, the fields, and a matching sanitize callback.
<?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.
Fix the highlighted fields to update the output.
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
- Keep one option holding an array. A dozen separate options is a dozen autoloaded rows on every request for one screen nobody visits.
- 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.
- 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.
- 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.
- 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.phponly. Code callingupdate_option()directly bypasses it entirely. - An unchecked checkbox sends nothing at all. Reading
$input['enabled']withoutisset()is a notice, and treating a missing key as unchanged means the box can never be turned off. register_setting()andadd_settings_field()belong onadmin_init, notadmin_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 inadd_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_restmakes 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?
settings_fields() does not match the one in register_setting(). Both have to be the same string.One option or one per field?
How do I add a second section?
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?
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?
delete_option() to uninstall.php, not to the deactivation hook.