Admin Menus & Pages
add_menu_page and add_submenu_page register admin screens on admin_menu; the capability argument hides the menu, it does not secure it.
add_menu_page( string $page_title, string $menu_title, string $capability, string $menu_slug, callable $callback = '', string $icon_url = '', int|float $position = null ): stringAdmin screens are registered on the admin_menu action. add_menu_page() creates a top-level entry and returns the page's hook suffix. add_submenu_page() nests one under an existing parent and returns the hook suffix, or false if the current user lacks the capability.
add_menu_page( $page_title, $menu_title, $capability, $menu_slug, $callback = '', $icon_url = '', $position = null ): string
add_submenu_page( $parent_slug, $page_title, $menu_title, $capability, $menu_slug, $callback = '', $position = null ): string|false
$position on add_submenu_page() arrived in WordPress 5.3; both accept int|float, and floats let you slot between existing items.
The wrappers
The add_*_page() family are one-line wrappers around the same function. add_options_page() is literally:
add_options_page( $t, $m, $cap, $slug, $cb );
// ===
add_submenu_page( 'options-general.php', $t, $m, $cap, $slug, $cb );
The rest differ only in the parent slug: edit.php, tools.php, themes.php, users.php, upload.php. For a custom post type the parent is edit.php?post_type=acme_event.
Capabilities
$capability decides who sees the menu item and who can load the screen. It does not protect the work the page performs — core's own documentation says the output callback must check the capability as well. Pair current_user_can() with a nonce before writing anything.
Notices
Echo on admin_notices. Since WordPress 6.4, wp_admin_notice( $message, $args ) builds the markup, taking type, dismissible, id and additional_classes. The hook fires on every admin screen, so gate it on the hook suffix or on get_current_screen().
Example
<?php
defined( 'ABSPATH' ) || exit;
// Stored so other callbacks (asset enqueuing, notices) can compare against it.
function acme_events_admin_menu(): void {
$hook = add_menu_page(
__( 'Acme Events', 'acme-events' ), // <title> text
__( 'Events', 'acme-events' ), // menu label
'edit_posts', // capability
'acme-events', // menu slug
'acme_events_dashboard_page', // output callback
'dashicons-calendar-alt', // Dashicon class, or a data: URI, or 'none'
26 // just under Comments
);
// Same slug as the parent: relabels the auto-created first submenu item
// from "Events" to "Dashboard".
add_submenu_page(
'acme-events',
__( 'Acme Events', 'acme-events' ),
__( 'Dashboard', 'acme-events' ),
'edit_posts',
'acme-events',
'acme_events_dashboard_page'
);
add_submenu_page(
'acme-events',
__( 'Acme Events Settings', 'acme-events' ),
__( 'Settings', 'acme-events' ),
'manage_options', // stricter than the parent: editors see the menu, not this item
'acme-events-settings',
'acme_events_settings_page'
);
// Load assets only on our screen. 'admin_print_styles-{$hook_suffix}' and
// 'load-{$hook_suffix}' are both keyed on the returned suffix.
add_action( "admin_enqueue_scripts", static function ( string $screen ) use ( $hook ): void {
if ( $screen !== $hook ) {
return;
}
wp_enqueue_style( 'acme-events-admin', ACME_EVENTS_URL . 'assets/admin.css', array(), ACME_EVENTS_VERSION );
} );
}
add_action( 'admin_menu', 'acme_events_admin_menu' );
function acme_events_dashboard_page(): void {
// The capability on add_menu_page() is not a substitute for this check.
if ( ! current_user_can( 'edit_posts' ) ) {
wp_die( esc_html__( 'You do not have permission to view this page.', 'acme-events' ) );
}
echo '<div class="wrap">';
echo '<h1>' . esc_html( get_admin_page_title() ) . '</h1>';
echo '</div>';
}When to use it
- Giving a plugin its own top-level menu with a dashboard and a settings subpage.
- Adding an import or maintenance tool under an existing Tools or custom post type menu.
- Loading a plugin's admin CSS and JavaScript on its own screens only.
- Showing a one-off confirmation notice after a form submission without leaking it onto every admin page.
More examples
A screen under a custom post type
Two patterns worth knowing: hanging a tool off a custom post type so it sits where the content does, and registering a page with a null parent so the URL resolves but nothing shows in the menu — the usual way to build a results or confirmation screen you redirect to.
<?php
defined( 'ABSPATH' ) || exit;
add_action( 'admin_menu', static function (): void {
// The parent slug for a CPT submenu includes the query string.
$hook = add_submenu_page(
'edit.php?post_type=acme_event',
__( 'Import Events', 'acme-events' ),
__( 'Import', 'acme-events' ),
'manage_options',
'acme-events-import',
'acme_events_import_page'
);
// false when the current user lacks manage_options - guard before using it.
if ( false === $hook ) {
return;
}
// 'load-{$hook_suffix}' fires before any output on that screen: the right
// place to process a POST, because you can still redirect.
add_action( "load-{$hook}", 'acme_events_handle_import' );
} );
// A hidden page - registered so its URL works, but with a null parent so it
// never appears in the menu.
add_action( 'admin_menu', static function (): void {
add_submenu_page(
null,
__( 'Import Results', 'acme-events' ),
'',
'manage_options',
'acme-events-import-results',
'acme_events_import_results_page'
);
} );Handling a POST with capability and nonce checks
The three checks every admin form needs, in order. Running the handler on load-{$hook_suffix} rather than inside the page callback is what makes the redirect possible — by the time the page callback runs, headers are already sent.
<?php
defined( 'ABSPATH' ) || exit;
function acme_events_handle_import(): void {
if ( 'POST' !== ( $_SERVER['REQUEST_METHOD'] ?? '' ) ) {
return;
}
// 1. Capability, checked again here and not trusted from add_submenu_page().
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have permission to import events.', 'acme-events' ), 403 );
}
// 2. Nonce. check_admin_referer() dies on failure.
check_admin_referer( 'acme_events_import' );
$count = acme_events_run_import( wp_unslash( $_POST['acme_source'] ?? '' ) );
// 3. Redirect so a refresh does not resubmit, carrying state in the query string.
wp_safe_redirect(
add_query_arg(
array( 'page' => 'acme-events-import', 'imported' => (int) $count ),
admin_url( 'edit.php?post_type=acme_event' )
)
);
exit;
}
function acme_events_import_page(): void {
?>
<div class="wrap">
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
<form method="post">
<?php wp_nonce_field( 'acme_events_import' ); ?>
<input type="url" name="acme_source" class="regular-text" required />
<?php submit_button( __( 'Import', 'acme-events' ) ); ?>
</form>
</div>
<?php
}Admin notices scoped to one screen
wp_admin_notice() produces the same markup the hand-written div does, but it escapes consistently and applies the wp_admin_notice_args filter so other code can adjust it. The screen check is the part people forget, and it is why sites end up with a plugin's success message on every page of wp-admin.
<?php
defined( 'ABSPATH' ) || exit;
add_action( 'admin_notices', static function (): void {
$screen = get_current_screen();
// admin_notices fires on every admin page. Without this, the notice
// follows the user around the whole dashboard.
if ( ! $screen || 'acme_event' !== $screen->post_type ) {
return;
}
$imported = isset( $_GET['imported'] ) ? absint( $_GET['imported'] ) : null;
if ( null === $imported ) {
return;
}
// wp_admin_notice( string $message, array $args ) - WordPress 6.4.
wp_admin_notice(
sprintf(
/* translators: %d: number of events imported. */
_n( '%d event imported.', '%d events imported.', $imported, 'acme-events' ),
$imported
),
array(
'type' => 'success',
'dismissible' => true,
'id' => 'acme-events-imported',
'additional_classes' => array( 'acme-notice' ),
)
);
} );
// Before 6.4 the equivalent longhand was:
// printf(
// '<div class="notice notice-success is-dismissible" id="acme-events-imported"><p>%s</p></div>',
// esc_html( $message )
// );
Discussion