Most plugins need at least one screen of their own: a settings page, a report, a tool. WordPress builds the admin menu from calls your plugin makes on the admin_menu action, and checks a capability for every page before showing it. This lesson adds two pages to the events plugin: a Bookings report and a Settings page, both under the Events menu.
Where pages should go
Before writing code, decide where the page belongs. Users find things faster when plugins follow the same patterns as core:
| Page | Put it under | Function |
|---|---|---|
| Settings for one feature | Settings | add_options_page() |
| A one-off tool, such as an import | Tools | add_management_page() |
| Screens for a post type you registered | That post type's menu | add_submenu_page( 'edit.php?post_type=...' ) |
| A large product with many screens | Its own top-level menu | add_menu_page() plus add_submenu_page() |
A top-level menu item for a small plugin is the admin equivalent of shouting. The events plugin already has an Events menu from its post type, so both new pages go there.
Registering submenu pages
<?php
// wp-content/plugins/myplugin/admin/menu.php
defined( 'ABSPATH' ) || exit;
add_action( 'admin_menu', 'myplugin_admin_menu' );
/**
* Add the Bookings and Settings pages under Events.
*/
function myplugin_admin_menu(): void {
$parent = 'edit.php?post_type=myplugin_event';
$bookings_hook = add_submenu_page(
$parent,
__( 'Bookings', 'myplugin' ), // Page title, in the browser tab.
__( 'Bookings', 'myplugin' ), // Menu label.
'myplugin_manage_bookings', // Capability required.
'myplugin-bookings', // Menu slug, used in the URL.
'myplugin_render_bookings_page' // Callback that prints the page.
);
add_submenu_page(
$parent,
__( 'Event settings', 'myplugin' ),
__( 'Settings', 'myplugin' ),
'manage_options',
'myplugin-settings',
'myplugin_render_settings_page'
);
if ( $bookings_hook ) {
add_action( 'load-' . $bookings_hook, 'myplugin_bookings_screen_load' );
}
}
The parent slug is the URL of the parent menu item, relative to wp-admin/. For a post type, that is edit.php?post_type= plus the key. For core menus, use the core file names: options-general.php, tools.php, users.php, index.php for the Dashboard.
The menu slug must be unique across the whole admin, so prefix it. WordPress builds the page URL from it: wp-admin/edit.php?post_type=myplugin_event&page=myplugin-bookings. To link to the page from code, use menu_page_url( 'myplugin-bookings', false ), which returns the URL instead of printing it.
Capabilities
The fourth argument is the capability a user needs to see the menu item and to open the page. If a user without it types the URL, WordPress stops with "Sorry, you are not allowed to access this page."
Choose the capability that matches what the page does:
manage_optionsfor site-wide settings. Only administrators have it by default.- A custom capability, such as
myplugin_manage_bookings, for a feature that some non-administrators need. The activation lesson added it to administrators; you can grant it to an editor or a custom role. - Never
readfor anything that changes data: every logged-in user has it.
The capability on the menu only protects the page display. Any code that saves data from the page must check the same capability again, because a request can reach your handler without ever loading the page.
A top-level menu, for comparison
When a plugin does need its own menu, add_menu_page() takes the same first five arguments plus an icon and a position:
<?php
// Example only: a top-level menu for a larger product.
add_menu_page(
__( 'Booking centre', 'myplugin' ),
__( 'Bookings', 'myplugin' ),
'myplugin_manage_bookings',
'myplugin-bookings',
'myplugin_render_bookings_page',
'dashicons-tickets-alt',
26
);
The icon is a Dashicons class name, a URL to an image, or a base64-encoded SVG data URI. The position is a number; core items use values such as 5 for Posts and 20 for Pages. Omit it unless the item genuinely belongs next to a particular core menu. WordPress then adds a first submenu item with the same title. To rename it, add a submenu page with the same slug as the parent.
Rendering the page
The callback prints the page body. WordPress has already printed the admin header and menu, and prints the footer after you return.
<?php
// wp-content/plugins/myplugin/admin/menu.php
/**
* Print the Bookings page: the 20 most recent bookings.
*/
function myplugin_render_bookings_page(): void {
if ( ! current_user_can( 'myplugin_manage_bookings' ) ) {
return;
}
global $wpdb;
$bookings = $wpdb->get_results(
$wpdb->prepare(
'SELECT id, event_id, user_id, guests, status, created_at FROM %i ORDER BY created_at DESC LIMIT %d',
$wpdb->prefix . 'myplugin_bookings',
20
)
);
?>
<div class="wrap">
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
<?php if ( ! $bookings ) : ?>
<p><?php esc_html_e( 'No bookings yet.', 'myplugin' ); ?></p>
<?php else : ?>
<table class="widefat striped">
<thead>
<tr>
<th scope="col"><?php esc_html_e( 'Event', 'myplugin' ); ?></th>
<th scope="col"><?php esc_html_e( 'Customer', 'myplugin' ); ?></th>
<th scope="col"><?php esc_html_e( 'Guests', 'myplugin' ); ?></th>
<th scope="col"><?php esc_html_e( 'Status', 'myplugin' ); ?></th>
<th scope="col"><?php esc_html_e( 'Booked', 'myplugin' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $bookings as $booking ) : ?>
<?php $user = get_userdata( (int) $booking->user_id ); ?>
<tr>
<td><?php echo esc_html( get_the_title( (int) $booking->event_id ) ); ?></td>
<td><?php echo esc_html( $user ? $user->display_name : __( 'Deleted user', 'myplugin' ) ); ?></td>
<td><?php echo esc_html( (string) $booking->guests ); ?></td>
<td><?php echo esc_html( $booking->status ); ?></td>
<td><?php echo esc_html( get_date_from_gmt( $booking->created_at, get_option( 'date_format' ) ) ); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php
}
The markup follows core conventions, so the page looks native without any CSS of your own:
- Wrap everything in
<div class="wrap">. Core styles and notices rely on it. - One
<h1>with the page title.get_admin_page_title()returns the title you registered. widefat stripedstyles a table like core lists.- Escape everything, including values from your own table.
created_atis stored in UTC, soget_date_from_gmt()converts it to the site's time zone for display.
The extra current_user_can() check at the top is defence in depth. WordPress already checks the menu capability, but the function might be called from somewhere else one day.
For a full list screen with sorting, pagination, bulk actions and search, core uses the WP_List_Table class. Many plugins extend it, but core marks it as private API that may change, so check it after major WordPress releases if you depend on it.
Preparing the screen: the load hook
add_submenu_page() returns a hook suffix, a string that identifies the page. WordPress fires load-{$hook_suffix} after the admin has loaded but before any output. That makes it the right place for three jobs:
<?php
// wp-content/plugins/myplugin/admin/menu.php
/**
* Runs before the Bookings page prints anything.
*/
function myplugin_bookings_screen_load(): void {
// 1. Assets for this screen only.
add_action( 'admin_enqueue_scripts', 'myplugin_bookings_assets' );
// 2. Contextual help in the Help tab.
get_current_screen()->add_help_tab(
array(
'id' => 'myplugin-bookings-help',
'title' => __( 'About bookings', 'myplugin' ),
'content' => '<p>' . esc_html__( 'Bookings are created when visitors RSVP to an event.', 'myplugin' ) . '</p>',
)
);
// 3. Form handling, which can still redirect because nothing is printed yet.
if ( isset( $_POST['myplugin_export'] ) ) {
myplugin_handle_bookings_export();
}
}
function myplugin_bookings_assets(): void {
wp_enqueue_style( 'myplugin-admin', MYPLUGIN_URL . 'assets/css/admin.css', array(), MYPLUGIN_VERSION );
}
Because admin_enqueue_scripts is only hooked from inside the load hook, the stylesheet loads on this screen and nowhere else. You never have to guess the hook suffix string.
The form handler (here myplugin_handle_bookings_export(), which you would write) must check the capability and a nonce before doing anything, exactly as in the WordPress course's security lessons. Handling the form in the load hook rather than in the render callback means you can send headers, redirect after a save, or stream a CSV file, because no HTML has been sent yet. The alternative, posting to admin-post.php, works too and suits forms that appear on several screens.
The settings page placeholder
The Settings submenu needs its callback to exist, or WordPress shows an error when you open it. Add a placeholder now; the next lesson fills it with the Settings API:
<?php
// wp-content/plugins/myplugin/admin/settings.php
defined( 'ABSPATH' ) || exit;
function myplugin_render_settings_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
?>
<div class="wrap">
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
</div>
<?php
}
Summary
- Register pages on
admin_menu; put them under an existing menu unless the plugin is large enough to need its own. - The capability argument hides the menu item and blocks the page, but save handlers must check it again.
- Prefix menu slugs, and build links with
menu_page_url( $slug, false ). - Print pages inside
<div class="wrap">with one<h1>, use core classes such aswidefat striped, and escape everything. - Use the hook suffix and the
load-{$hook_suffix}action to load assets, add help tabs and handle forms before output starts.