Register admin menu and submenu pages from a callback on this action, which fires once the admin is loaded and the current user's capabilities are known. It is the only safe place to call add_menu_page() and add_submenu_page(): earlier and the capability checks are not ready, later and the menu has already been rendered. Priority matters more here than almost anywhere else in WordPress, because removing another plugin's menu item requires running after it added one.
Fires before the administration menu loads in the admin.
$contextstringEvery example is editable and runs in a real WordPress booted in your browser by WordPress Playground. Press Run, then edit the code: clicking away re-runs it. Nothing is sent anywhere until you do.
The callback runs inside the admin, where add_menu_page() registers both the menu item and the screen that renders when it is clicked.
add_action( 'admin_menu', function () {
add_menu_page(
'Field Notes', // page <title>
'Field Notes', // menu label
'manage_options', // capability required
'field-notes', // menu slug, becomes ?page=field-notes
'wppaste_render_field_notes', // renders the screen
'dashicons-clipboard',
25 // position, below Comments
);
} );
function wppaste_render_field_notes() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You are not allowed to view this page.' ) );
}
echo '<div class="wrap"><h1>Field Notes</h1>';
echo '<p>Look at the left-hand menu: this item was added by the snippet.</p>';
echo '</div>';
}Check the capability again inside the render callback: the menu being hidden is not access control.
Most plugins do not need a top-level item; a submenu under Settings is less intrusive and easier for people to find.
add_action( 'admin_menu', function () {
// Settings > Field Notes. The parent slug is the parent's file name.
add_submenu_page(
'options-general.php',
'Field Notes Settings',
'Field Notes',
'manage_options',
'field-notes-settings',
function () {
echo '<div class="wrap"><h1>Field Notes Settings</h1>';
echo '<p>Open Settings in the sidebar to find this page.</p></div>';
}
);
} );add_options_page() is a thin wrapper around this with the parent slug filled in.
Removal has to happen after the item exists, which is what the priority argument is for.
// Something else registers a menu at the default priority of 10.
add_action( 'admin_menu', function () {
add_menu_page( 'Noisy Plugin', 'Noisy Plugin', 'manage_options', 'noisy-plugin', '__return_null', 'dashicons-megaphone', 26 );
} );
// Priority 999 runs last, so the item is there to remove.
add_action( 'admin_menu', function () {
remove_menu_page( 'noisy-plugin' );
}, 999 );
// Removing the menu does not block the URL, so gate the screen too.
add_action( 'admin_init', function () {
if ( isset( $_GET['page'] ) && 'noisy-plugin' === $_GET['page'] ) {
wp_die( esc_html__( 'That screen has been disabled.' ) );
}
} );Run it, then look for "Noisy Plugin" in the sidebar: it was registered and then removed.
Three causes, in the order worth checking.
- The current user lacks the capability you passed. add_menu_page() silently registers nothing the user cannot see, so a typo like manage_option produces no menu and no error.
- The callback was hooked too late. Anything added after admin_menu has finished, for instance from admin_init or from inside a template, arrives after the menu is built.
- You are on a screen this hook does not run on. The network admin fires network_admin_menu and the user admin fires user_admin_menu; admin_menu covers neither.
Almost always priority. Both the plugin that adds the item and your removal are on the same hook, and the default priority of 10 means yours can run first, removing something that does not exist yet.
``
add_action( 'admin_menu', 'my_removal', 999 );
``
Also note the slug argument is the menu slug, not the label: for a core item it is the file name, such as edit-comments.php. And removal only hides the item, it does not protect the screen, so check the capability on admin_init too.
admin_menu fires once the admin is loaded and is where the menu is assembled; it is the only correct place to register menu and submenu pages. admin_init fires on every admin request afterwards and is where settings registration, redirects and form handling belong. Registering a menu on admin_init is too late, and running a redirect on admin_menu fires before the screen has been resolved.
No to the front end. It runs on admin page loads only, which is why the callback can assume is_admin() is true. AJAX is the trap: admin-ajax.php is an admin request, so admin_menu does fire there, and building a menu on every AJAX call is wasted work. If the callback does anything expensive, guard it with wp_doing_ajax().
network_admin_menuadmin_initadd_menu_pageadmin_bar_menuwp-admin/includes/menu.php:168file scope * Fires before the administration menu loads in the admin. * * @since 1.5.0 * * @param string $context Empty context. */ do_action( 'admin_menu', '' );} /* * Remove menus that have no accessible submenus and require privileges * that the user does not have. Run re-parent loop again. */Introduced in 1.5.0. Unchanged from 6.7.7 through 7.1.0.
Signature, return type and hooks compared across 5 parsed releases.