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 ID | Notice rendered |
|---|---|
dashboard | no |
edit-post | no |
plugins | no |
toplevel_page_wpsifero-reports | yes |
reports_page_wpsifero-import | yes |
reports_page_wpsifero-typo (not a real screen) | no |
settings_page_wpsifero-report-settings | yes |
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.
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.