Skip to main content

โš™๏ธ 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.
flowchart TB A[WordPress Admin] --> B[Admin Menu API] A --> C[Settings API] A --> D[Admin Notices] B --> E[Top-level pages] B --> F[Submenu pages] C --> G[Sections] C --> H[Fields] G --> I[Registration] H --> J[Rendering & sanitizing]

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.

Settings API hierarchy A settings group contains sections, and each section contains individual fields. Settings group register_setting() Section add_settings_section() Section add_settings_section() Fields ยท add_settings_field() Fields ยท add_settings_field()
Figure 1 โ€” A settings group holds sections; each section holds fields. This three-level shape maps directly onto the three registration functions.

The flow is always the same, registered on admin_init:

  1. Register the setting with a sanitize callback (register_setting).
  2. Add sections to visually group fields (add_settings_section).
  3. 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.

ClassColourUse for
notice-successGreenAn action completed
notice-infoBlueNeutral information
notice-warningAmberA heads-up about a potential problem
notice-errorRedSomething 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:

  1. Add a top-level menu page (or use add_options_page()) with a render callback that outputs the settings form.
  2. Register one setting group storing an array, with a sanitize callback.
  3. Add one section and three fields: a text input, a checkbox, and a select.
  4. Sanitize each field appropriately in the sanitize callback.
  5. 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_menu with add_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.