Skip to content
System nominalUTC --:--:--Articles 080
MODULE 03 Lesson 08 / 18 25 MIN

The admin side

Admin Menus, Screens and Notices

add_menu_page against add_submenu_page, the arguments that change behaviour, how to hang a page off an existing menu, and why a notice needs a screen check.

You add a notice to warn about a stale index. It works, so you ship it. Two weeks later someone reports that a yellow bar follows them across every screen in the admin, including the plugin installer, and their client has taken a screenshot of it. Nothing in the code is broken. The notice is registered on the right hook and renders correctly; it renders everywhere, on every screen, for every user.

admin_notices is a global hook. It fires inside the admin header, after the screen has been set up but before any page content, and it has no idea which plugin is speaking. Scoping a notice is a job you do on purpose, using the screen object WordPress builds for every admin request. That same object is what you use to load assets only where they are needed, which is the next lesson. Menus come first, because the menu is what decides whether your screen exists at all.

add_menu_page adds a section, add_submenu_page adds a line inside one

These two functions are not variations on a theme. add_menu_page( $page_title, $menu_title, $capability, $menu_slug, $callback, $icon_url, $position ) creates a new entry in the left sidebar with its own icon. add_submenu_page( $parent_slug, ... ) adds a line beneath an existing one and has no icon or position argument of its own.

The overhead is the reason to care. A top-level menu costs a permanent place in the sidebar that the site owner did not ask for and cannot reorganise without another plugin. Every plugin installed adds one, and the sidebar stops being navigable. A plugin with a single settings page and four fields does not deserve a section; it deserves a line under Settings, which add_options_page() gives you as a three-line wrapper around add_submenu_page( 'options-general.php', ... ).

The test I apply: a top-level menu is for content the site owner visits repeatedly as part of their work. Anything else is a submenu under an existing section, or a tab inside a page that already exists.

The first two arguments are different titles and only one is a label

The most common mistake with these functions is passing the same string twice because nobody explained the difference. $page_title becomes the <title> of the browser tab. $menu_title becomes the sidebar label. They are allowed to differ, and the useful pattern is a short menu label with a longer page title.

add_menu_page(
    __( 'WPSifero Reports', 'wpsifero' ),   // browser tab and screen heading data
    __( 'Reports', 'wpsifero' ),            // sidebar label
    'manage_options',                       // capability
    'wpsifero-reports',                     // slug
    'wpsifero_render_reports_page',         // callback
    'dashicons-chart-bar',                  // icon
    81                                      // position
);

The slug is the important one, because it does more than name a URL parameter. It is the key the admin menu system uses to derive three other strings: the $admin_page_hooks entry, the hook name your callback is attached to, and, through that hook name, the screen ID you will match on later.

The menu title becomes the hook prefix, and that decides your screen ID

When you register a top-level page, add_menu_page() stores sanitize_title( $menu_title ) in $admin_page_hooks[ $menu_slug ]. For the call above that entry is $admin_page_hooks['wpsifero-reports'] = 'reports', taken from the menu label "Reports". When you then add a submenu to that parent, the hook name is built as {page_type}_page_{slug}, so the submenu slug wpsifero-import becomes reports_page_wpsifero-import.

I ran the registrations from this lesson against the menu functions cut out of the 7.0.7 tree. The output below is that run with the $menu and $submenu entries formatted one per line for readability; the hook on the menu row is the fifth element of the array, which core prefixes with menu-top:

admin_page_hooks: {"wpsifero-reports":"reports"}

menu[81] = Reports | cap=manage_options | slug=wpsifero-reports | hook=toplevel_page_wpsifero-reports
submenu[wpsifero-reports][0] = All Reports | cap=manage_options | slug=wpsifero-reports
submenu[wpsifero-reports][1] = Import      | cap=manage_options | slug=wpsifero-import
submenu[options-general.php][0] = Report Settings | cap=manage_options | slug=wpsifero-report-settings

callback actions registered: toplevel_page_wpsifero-reports
                             reports_page_wpsifero-import
                             settings_page_wpsifero-report-settings

Three details are worth reading off that output. The submenu array contains an entry for the parent slug itself, at index 0, which I never registered. WordPress adds it: when a menu has no submenu yet, add_submenu_page() copies the parent's own entry into the submenu list, which is why the first line under "Reports" is "All Reports". It is also why registering a submenu whose slug equals the parent's produces no duplicate label. Second, the hook prefix for the Settings page is settings_, not options-general, because core's compat map translates the parent file name. Third, the top-level callback is hooked on toplevel_page_wpsifero-reports. Rename the menu label to "WPS Reports" and every submenu hook prefix changes with it, which is the reason to read the prefix from the return value of the registration call rather than hardcoding it.

Position is a sort key, and a collision gets a fractional offset

Core builds its sidebar on fixed integers: Dashboard 2, Media 10, Links 15, a separator at 59, Appearance 60, Plugins 65, Tools 75, Settings 80, a final separator at 99. Passing null, the default, appends your menu to the array's end. Passing a number that no one has used places you exactly there.

Passing a number that is already used does not overwrite anything and does not fail. In the 7.0.7 body of add_menu_page(), a collision computes base_convert( substr( md5( $slug . $title ), -4 ), 16, 10 ) * 0.00001 and adds it to your position, so two menus land at 80 and 80.000012 and the sort between them is decided by a hash of your strings. That is stable per plugin, which is why nobody notices, and opaque to a human reading the code. Pick an unused integer, ideally the next free slot after the section you belong to.

A submenu can hang off somebody else's menu

The parent slug is a slug, not an entitlement. Passing 'tools.php', 'options-general.php', 'users.php' or a slug registered by another plugin puts your page inside that menu, with that menu's capability requirements still applying to the parent entry. The page appears when the parent does, and disappears with it.

Two habits follow. Do not rely on the parent menu being present: a site owner can hide the Tools menu with a plugin, or another plugin can rename its parent slug between versions, and your page goes with it. And do not make a submenu of a menu you do not own your only route in. If your page is the plugin's only surface, a Settings submenu is a dependency on core's menu staying where it is, which is a safe bet; a submenu of a third-party plugin's custom menu is not.

A notice without a screen check appears on every screen

get_current_screen() returns the WP_Screen object for the admin request, or null when there is no screen, which is the front end and some AJAX contexts. It has an id equal to the hook name, so the three IDs you compared above are exactly the strings you match on.

I called the notice callback for the plugin above against seven screens, with the stale-sync condition set to true. Only the three registered screens rendered anything:

Screen IDNotice rendered
dashboardno
edit-postno
pluginsno
toplevel_page_wpsifero-reportsyes
reports_page_wpsifero-importyes
reports_page_wpsifero-typo (not a real screen)no
settings_page_wpsifero-report-settingsyes
add_action( 'admin_notices', 'wpsifero_maybe_render_sync_notice' );

function wpsifero_maybe_render_sync_notice(): void {
    $screen = get_current_screen();

    $ours = array(
        'toplevel_page_wpsifero-reports',
        'reports_page_wpsifero-import',
        'settings_page_wpsifero-report-settings',
    );

    if ( ! $screen || ! in_array( $screen->id, $ours, true ) ) {
        return;
    }

    if ( ! wpsifero_sync_is_stale() ) {
        return;
    }

    printf(
        '<div class="notice notice-warning is-dismissible" id="wpsifero-sync-notice"><p>%s</p></div>',
        esc_html__( 'The report index has not been rebuilt in over a day.', 'wpsifero' )
    );
}

The list of IDs is the part that offends people, and there is no supported way around it. A prefix match on reports_page_ would catch every screen registered by any plugin whose menu label happens to sanitize to the same prefix, including one that registered first. An exact list is long and correct, which beats short and wrong.

For a notice about the plugin as a whole, the honest scope is a set of screens, not all of them. There is no "show this once per admin session anywhere" hook that makes a global notice the right call, and the WordPress admin is a shared surface that other plugins and core also draw on.

is-dismissible hides the notice and stores nothing

The class looks like a feature and is half of one. wp-admin/js/common.js scans for .notice.is-dismissible on page load, appends a <button class="notice-dismiss"> to each one, and on click fades the element out and removes it from the DOM. That is all it does. Nothing is written to the database, and the notice returns on the next page load.

Persistence is your job, and the conventional store is user meta, keyed by notice and user, so that one administrator dismissing a notice does not hide it from their colleagues. Core's own pointer-dismissal handler, wp_ajax_dismiss_wp_pointer(), stores its state as a comma-separated list in the dismissed_wp_pointers user meta key. Copy the shape, not the details:

add_action( 'wp_ajax_wpsifero_dismiss_notice', 'wpsifero_ajax_dismiss_notice' );

function wpsifero_ajax_dismiss_notice(): void {
    check_ajax_referer( 'wpsifero_dismiss_notice' );

    if ( ! current_user_can( 'manage_options' ) ) {
        wp_send_json_error( array( 'message' => 'forbidden' ), 403 );
    }

    update_user_meta( get_current_user_id(), 'wpsifero_notice_dismissed', 1 );
    wp_send_json_success();
}

Then the render side reads the same key and returns early when it is set, which I verified by running the callback, checking get_user_meta() came back as 1, and calling the notice renderer again: it produced nothing. The check_ajax_referer() call has no second argument, so it looks in $_REQUEST['_ajax_nonce'] first and $_REQUEST['_wpnonce'] second; your JavaScript sends the nonce created with wp_create_nonce( 'wpsifero_dismiss_notice' ). The next lesson covers the two nonce functions in detail, including why they are two functions.

Lab 30 minutes

Give a plugin of yours one top-level menu with two submenus, plus one settings page under Settings, keeping the menu label and the page titles different so you can see which one ends up where.

Then do three things. Predict each screen ID before you load the page, and check the prediction by reading $screen->id on a current_screen callback or by inspecting the body element's class list. Make the notice render on one of the four screens only, and confirm it is absent on the other three. Finally, add is-dismissible, click the button, and explain what you observe on the next page load and what would have to change for the dismissal to hold.

Show the solution and the reasoning

The screen IDs follow from the hook names, and the hook names follow from the menu titles. With a top-level menu labelled "Reports" and slug wpsifero-reports, the parent screen is toplevel_page_wpsifero-reports, and the two submenus are reports_page_import and reports_page_export if you chose those slugs. The Settings page is settings_page_wpsifero-report-settings, because the parent is options-general.php and core's compat map turns that file name into settings.

The trap is renaming the menu label to something whose sanitized form differs. Change "Reports" to "WPS Reports" and every submenu hook becomes wps-reports_page_.... Any code that matched on the old ID silently stops firing, and it does so without an error, because a string comparison against a screen that no longer exists is a false. The fix is to stop deriving the ID by hand. Capture the return value of the registration call, which is the hook name, and build the list from those values:

function wpsifero_register_report_menus(): void {
    $hooks = array();

    $hooks['reports'] = add_menu_page(
        __( 'WPSifero Reports', 'wpsifero' ),
        __( 'Reports', 'wpsifero' ),
        'manage_options',
        'wpsifero-reports',
        'wpsifero_render_reports_page',
        'dashicons-chart-bar',
        81
    );

    $hooks['import'] = add_submenu_page(
        'wpsifero-reports',
        __( 'Import', 'wpsifero' ),
        __( 'Import', 'wpsifero' ),
        'manage_options',
        'wpsifero-import',
        'wpsifero_render_import_page'
    );

    update_option( 'wpsifero_report_hooks', $hooks, false );
}

Storing the hook names in a non-autoloaded option is one way to share them with the notice callback and the asset loader. Reading them on every request is a query unless the option is cached, so the request-scoped alternative is a small function that recomputes the prefix, or capturing the hooks on admin_menu into a static. Do not reach for admin_head-{$hook_suffix} here: that hook fires in the header, before admin_notices runs, so anything echoed from it lands above the notice area. The screen check inside the notice callback is the mechanism.

The notice renders on one screen because the ID list contains one entry. To make it render on exactly one of four, the list itself is the whole implementation; there is no caching or ordering subtlety.

Dismissal does not stick, and the reason is the split between the JavaScript and the server. is-dismissible gives you the button and the animation, and the handler the button calls is core's own: it removes the element. Nothing tells your plugin, so the next page load re-renders the notice from the same condition. To make it hold you need an AJAX action registered with wp_ajax_{action}, a nonce the script can send, and a user meta write that the render condition reads. The class is the cosmetic half; the persistence half is yours, and it is per user, which is what you want on a site with more than one administrator.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.