โ๏ธ Admin Pages and Settings API
A great plugin needs a great control panel. In this lesson you'll add menu pages to wp-admin and use the Settings API โ WordPress's built-in framework for saving, validating, and rendering plugin options securely โ so your settings feel like a native part of the dashboard.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Register top-level and submenu admin pages with the Admin Menu API
- Use the Settings API to register settings, sections, and fields
- Render every common field type and sanitize input on save
- Build a tabbed settings page and show admin notices
- Wire up secure AJAX for interactive admin actions
Estimated Time: 40โ50 minutes โข Difficulty: Intermediate
Hands-on: Build a settings page with three field types, sanitization, and a saved-confirmation notice.
In This Lesson
Admin Interfaces Overview
The WordPress admin area (wp-admin) has a consistent look and set of components. Your plugin's screens should feel like they belong there โ same buttons, same form styling, same menu placement โ not like aftermarket parts bolted on.
๐ก Analogy: Think of wp-admin as a car's dashboard. Your plugin's controls should look and behave like factory-installed gauges, not a phone taped to the windscreen. WordPress gives you the APIs to build native-feeling controls โ use them.
Two APIs do most of the work: the Admin Menu API places your page in the sidebar, and the Settings API handles the form's structure, saving, and security.
The Settings API
The Settings API is a small framework that handles the tedious, error-prone parts of a settings form: the nonce, the save handler, validation, and storage. You describe your settings; WordPress does the plumbing.
The flow is always the same, registered on admin_init:
- Register the setting with a sanitize callback (
register_setting). - Add sections to visually group fields (
add_settings_section). - Add fields to sections, each with a render callback (
add_settings_field).
<?php
function my_plugin_register_settings(): void {
register_setting(
'my_plugin_options', // Option group (matches settings_fields()).
'my_plugin_options', // Option name stored in wp_options.
array(
'sanitize_callback' => 'my_plugin_sanitize',
'default' => array( 'text_field' => '', 'enabled' => 0 ),
)
);
add_settings_section(
'my_plugin_general', // Section ID.
'General Settings', // Title.
'my_plugin_general_intro', // Intro callback.
'my-plugin' // Page slug (matches do_settings_sections()).
);
add_settings_field(
'text_field', // Field ID.
'Display Name', // Label.
'my_plugin_text_field', // Render callback.
'my-plugin', // Page slug.
'my_plugin_general', // Section ID.
array( 'label_for' => 'text_field' )
);
}
add_action( 'admin_init', 'my_plugin_register_settings' );
function my_plugin_general_intro(): void {
echo '<p>Configure the core behaviour of the plugin.</p>';
}
function my_plugin_sanitize( array $input ): array {
$clean = array();
$clean['text_field'] = sanitize_text_field( $input['text_field'] ?? '' );
$clean['enabled'] = empty( $input['enabled'] ) ? 0 : 1;
return $clean;
}
โ Why the sanitize callback matters most
WordPress calls your sanitize_callback automatically every time the form saves. It's your single, guaranteed chokepoint for cleaning data โ never trust the raw $input, and always return a fully-scrubbed array.
Rendering Field Types
Each field's render callback outputs one form control. The pattern is identical every time: read the saved options, then echo the input pre-filled and properly escaped.
Text field
<?php
function my_plugin_text_field(): void {
$options = get_option( 'my_plugin_options' );
$value = $options['text_field'] ?? '';
printf(
'<input type="text" id="text_field" name="my_plugin_options[text_field]" value="%s" class="regular-text">',
esc_attr( $value )
);
echo '<p class="description">Shown at the top of each page.</p>';
}
Checkbox
<?php
function my_plugin_checkbox_field(): void {
$options = get_option( 'my_plugin_options' );
$checked = checked( 1, $options['enabled'] ?? 0, false );
printf(
'<label><input type="checkbox" name="my_plugin_options[enabled]" value="1" %s> Enable this feature</label>',
$checked
);
}
Select dropdown
<?php
function my_plugin_select_field(): void {
$options = get_option( 'my_plugin_options' );
$value = $options['mode'] ?? 'auto';
$choices = array( 'auto' => 'Automatic', 'light' => 'Light', 'dark' => 'Dark' );
echo '<select name="my_plugin_options[mode]">';
foreach ( $choices as $key => $label ) {
printf(
'<option value="%s" %s>%s</option>',
esc_attr( $key ),
selected( $value, $key, false ),
esc_html( $label )
);
}
echo '</select>';
}
๐ก Use WordPress's helper functions
checked(), selected(), and disabled() print the right attribute only when the values match โ passing false as the last argument returns the string instead of echoing it. They save you a stack of ternary expressions and read cleanly.
Other common field types follow the same shape: textarea (escape with esc_textarea()), radio groups (loop like the select), and a color picker (enqueue wp-color-picker and add .wpColorPicker() in your admin JS). Learn one and you've learned them all โ they're standardized building blocks you mix and match.
Tabbed Settings Pages
Once you have more than a handful of settings, tabs keep the page approachable. Track the active tab in a query parameter and register a separate settings group per tab.
<?php
function my_plugin_render_tabbed_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'general';
$tabs = array( 'general' => 'General', 'advanced' => 'Advanced' );
?>
<div class="wrap">
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
<nav class="nav-tab-wrapper">
<?php foreach ( $tabs as $slug => $label ) :
$active = ( $tab === $slug ) ? ' nav-tab-active' : ''; ?>
<a href="<?php echo esc_url( admin_url( 'admin.php?page=my-plugin&tab=' . $slug ) ); ?>"
class="nav-tab<?php echo esc_attr( $active ); ?>">
<?php echo esc_html( $label ); ?>
</a>
<?php endforeach; ?>
</nav>
<form method="post" action="options.php">
<?php
if ( 'advanced' === $tab ) {
settings_fields( 'my_plugin_advanced' );
do_settings_sections( 'my-plugin-advanced' );
} else {
settings_fields( 'my_plugin_general' );
do_settings_sections( 'my-plugin-general' );
}
submit_button();
?>
</form>
</div>
<?php
}
Register my_plugin_general and my_plugin_advanced as two independent settings groups on admin_init, each with its own sections and fields. It's like a filing cabinet with a labelled drawer per category โ the user opens only the one they need.
Admin Notices
Notices are the coloured message banners at the top of admin screens. They give users feedback โ success, info, warning, or error.
| Class | Colour | Use for |
|---|---|---|
notice-success | Green | An action completed |
notice-info | Blue | Neutral information |
notice-warning | Amber | A heads-up about a potential problem |
notice-error | Red | Something went wrong |
<?php
function my_plugin_admin_notices(): void {
// Warn if a required API key is missing.
$options = get_option( 'my_plugin_options' );
if ( empty( $options['api_key'] ) ) {
$url = admin_url( 'admin.php?page=my-plugin' );
printf(
'<div class="notice notice-warning"><p>My Plugin needs an API key. <a href="%s">Configure it now</a>.</p></div>',
esc_url( $url )
);
}
}
add_action( 'admin_notices', 'my_plugin_admin_notices' );
๐ Settings API notices come free
When you use register_setting() with an options-page form, WordPress automatically shows a "Settings saved." success notice after a save โ you don't have to build one yourself. Add the is-dismissible class to any custom notice to give it a close button.
Secure AJAX in Admin
For interactions that shouldn't reload the page โ a "Refresh data" button, a live search โ WordPress routes AJAX through admin-ajax.php. Every request must carry a nonce, and your handler must verify it and check capabilities.
Enqueue the script and pass it data
<?php
function my_plugin_admin_assets( string $hook ): void {
// Only load on our own page.
if ( 'toplevel_page_my-plugin' !== $hook ) {
return;
}
wp_enqueue_script(
'my-plugin-admin',
plugin_dir_url( __FILE__ ) . 'js/admin.js',
array( 'jquery' ),
'1.0.0',
true
);
// Hand the JS the AJAX URL and a fresh nonce.
wp_localize_script( 'my-plugin-admin', 'myPluginData', array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'my_plugin_ajax' ),
) );
}
add_action( 'admin_enqueue_scripts', 'my_plugin_admin_assets' );
Handle the request on the server
<?php
function my_plugin_ajax_refresh(): void {
// 1. Verify the nonce (dies with -1 on failure).
check_ajax_referer( 'my_plugin_ajax', 'nonce' );
// 2. Verify the capability.
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( 'Permission denied', 403 );
}
// 3. Do the work with sanitized input.
$item_id = isset( $_POST['item_id'] ) ? absint( $_POST['item_id'] ) : 0;
wp_send_json_success( array(
'item_id' => $item_id,
'value' => wp_rand( 1, 100 ),
'updated' => current_time( 'mysql' ),
) );
}
// Logged-in users only. Add wp_ajax_nopriv_ for public endpoints.
add_action( 'wp_ajax_my_plugin_refresh', 'my_plugin_ajax_refresh' );
Call it from JavaScript
jQuery(function ($) {
$('.my-plugin-refresh').on('click', async function (e) {
e.preventDefault();
const res = await fetch(myPluginData.ajaxUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
action: 'my_plugin_refresh', // Matches wp_ajax_{action}.
nonce: myPluginData.nonce,
item_id: $(this).data('item-id'),
}),
});
const json = await res.json();
console.log(json.success ? json.data : json.data);
});
});
โ ๏ธ The AJAX security checklist
- Nonce โ
check_ajax_referer()proves the request came from your UI. - Capability โ
current_user_can()proves the user is allowed. - Sanitize โ clean every value read from
$_POST.
Skip any one of these and you've opened a hole. All three, every time.
Hands-on Exercise
๐๏ธ Build a real settings page
Objective: Create a working options page using the Settings API.
Instructions:
- Add a top-level menu page (or use
add_options_page()) with a render callback that outputs the settings form. - Register one setting group storing an array, with a sanitize callback.
- Add one section and three fields: a text input, a checkbox, and a select.
- Sanitize each field appropriately in the sanitize callback.
- Save the form and confirm the values persist and the "Settings saved." notice appears.
๐ก Hint
The magic pairing is the strings: the first argument of settings_fields() must equal your option group, and the argument to do_settings_sections() must equal the page slug you passed to add_settings_section() and add_settings_field(). If fields don't appear, those strings are usually mismatched.
โ Example solution (sanitize callback)
<?php
function my_plugin_sanitize( array $input ): array {
$out = array();
// Text: strip tags and trim.
$out['display_name'] = sanitize_text_field( $input['display_name'] ?? '' );
// Checkbox: force a clean 1 or 0.
$out['enabled'] = empty( $input['enabled'] ) ? 0 : 1;
// Select: only allow known values.
$allowed = array( 'auto', 'light', 'dark' );
$mode = $input['mode'] ?? 'auto';
$out['mode'] = in_array( $mode, $allowed, true ) ? $mode : 'auto';
return $out;
}
Whitelisting the select value with in_array() is the key defensive move โ never assume a dropdown only returns the options you rendered.
๐ฏ Quick Quiz
Question 1: On which hook should you register menu pages and settings?
Question 2: Where does the Settings API guarantee your data gets cleaned before it's stored?
Question 3: An admin AJAX handler must always verify two things before doing work. Which pair?
Summary & Quiz
๐ Key Takeaways
- Register admin pages on
admin_menuwithadd_menu_page()/add_submenu_page(); attach small plugins to core menus instead of adding clutter. - The Settings API handles nonces, saving, and validation โ you register a group โ sections โ fields on
admin_init. - The sanitize callback is your one guaranteed chokepoint for cleaning input; always whitelist known values.
- Tabs tame large pages, and admin notices give clear feedback (Settings saved comes free).
- Admin AJAX demands a nonce, a capability check, and sanitized input โ every request, no exceptions.
๐ Further Reading
๐ What's Next?
You now have every core skill for PHP and WordPress plugins. It's time to put them together โ the Weekend Project has you build a complete plugin end to end.
๐ Dashboard ready!
Your plugins can now offer polished, secure admin controls. On to the weekend build.