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.

Syntaxadd_menu_page( string $page_title, string $menu_title, string $capability, string $menu_slug, callable $callback = '', string $icon_url = '', int|float $position = null ): string

Admin 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

Example · php
<?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.

Example · php
<?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.

Example · php
<?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.

Example · php
<?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

  • Be the first to comment on this lesson.