The Admin Dashboard
A developer's map of /wp-admin: which file each menu is, the capability that gates it, and the screens you will actually extend.
add_action( 'admin_menu', 'callback' ); // menus exist only inside this hookThe admin is a set of PHP files under wp-admin/, each bootstrapped through wp-admin/admin.php. Every top-level menu is a pairing of a file and a capability, registered in wp-admin/menu.php. If the current user lacks the capability, the menu is never added — the admin hides what you cannot do rather than showing a disabled item.
| Menu | File | Capability |
|---|---|---|
| Dashboard | index.php | read |
| Posts | edit.php | edit_posts |
| Pages | edit.php?post_type=page | edit_pages |
| Media | upload.php | upload_files |
| Appearance | themes.php | switch_themes, else edit_theme_options |
| Plugins | plugins.php | activate_plugins |
| Users | users.php | list_users |
| Tools | tools.php | edit_posts |
| Settings | options-general.php | manage_options |
What a developer actually touches
Settings > Permalinks — visiting it flushes rewrite rules, which is the fix for a custom post type 404ing. Settings > Reading — sets the front page and posts_per_page, which your queries inherit. Tools > Site Health (WordPress 5.2) — the Info tab reports the real PHP version, memory limit and database version. Appearance > Editor opens site-editor.php for block themes; classic themes get Customize instead.
What trips people up
Registering a custom post type after the admin_menu hook has run leaves it with no menu entry at all, even though the type exists. Register on init.
Example
<?php
/**
* Plugin Name: Acme Admin Tidy
* Description: Trims the admin for editors and adds a sync column to Posts.
*/
defined( 'ABSPATH' ) || exit;
/**
* remove_menu_page() (WordPress 3.1) must run on 'admin_menu' — the global
* $menu does not exist before then. The slug is the file the menu points at.
* Priority 999 so post types and plugin menus are already registered.
*/
add_action( 'admin_menu', static function () {
if ( current_user_can( 'manage_options' ) ) {
return; // Leave administrators alone.
}
remove_menu_page( 'tools.php' );
remove_menu_page( 'edit-comments.php' );
remove_submenu_page( 'themes.php', 'nav-menus.php' );
}, 999 );
/**
* Add a column to the Posts list table (wp-admin/edit.php). The hook names
* are dynamic: manage_{$post_type}_posts_columns and
* manage_{$post_type}_posts_custom_column.
*/
add_filter( 'manage_post_posts_columns', static function ( array $columns ): array {
$columns['acme_synced'] = __( 'Synced', 'acme' );
return $columns; // A filter must return the value it was given.
} );
// The custom_column action passes two arguments, so declare 2 explicitly —
// add_action() only passes one by default.
add_action( 'manage_post_posts_custom_column', static function ( string $column, int $post_id ): void {
if ( 'acme_synced' !== $column ) {
return;
}
$synced = get_post_meta( $post_id, '_acme_synced_at', true );
echo esc_html( $synced ? wp_date( 'Y-m-d H:i', (int) $synced ) : '—' );
}, 10, 2 );When to use it
- Hiding admin menus an editor role should never see on a client site.
- Adding a status column to a post type's list table for content operations.
- Finding the real PHP and MySQL versions of a site you did not build, via Tools > Site Health.
- Flushing rewrite rules after registering a custom post type that returns 404.
More examples
Building admin URLs: shorthand and longhand
Every admin link you generate should come from these functions. admin_url() honours FORCE_SSL_ADMIN and the site's real admin path, and get_admin_url() is the version to reach for as soon as multisite is involved.
<?php
// admin_url() (WordPress 2.6) is the shorthand you will see everywhere:
$url = admin_url( 'edit.php?post_type=page' );
// It is a thin wrapper for get_admin_url(), which takes a site ID first —
// the form you need on multisite:
$url = get_admin_url( null, 'edit.php?post_type=page' ); // identical output
$url = get_admin_url( 3, 'upload.php' ); // site 3's Media screen
// Do NOT hand-build it. This breaks the moment the admin lives elsewhere,
// is served over a different scheme, or the install is a subdirectory:
// $url = site_url() . '/wp-admin/edit.php';
// Add query args safely rather than concatenating:
$filtered = add_query_arg(
array(
'post_type' => 'acme_case_study',
'post_status' => 'draft',
'orderby' => 'modified',
),
admin_url( 'edit.php' )
);
printf( '<a href="%s">%s</a>', esc_url( $filtered ), esc_html__( 'Stale drafts', 'acme' ) );A notice on one screen only
The right way to attach admin UI to one screen. Dumping $screen->id for the page you care about is also the fastest way to find the identifier you need for screen-specific enqueues and help tabs.
<?php
// 'admin_notices' fires on every admin page, so unscoped notices become noise.
// get_current_screen() is available from the 'current_screen' hook onwards.
add_action( 'admin_notices', static function () {
$screen = get_current_screen();
if ( ! $screen instanceof WP_Screen || 'options-permalink' !== $screen->id ) {
return;
}
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
printf(
'<div class="notice notice-info"><p>%s</p></div>',
esc_html__( 'Saving this screen rebuilds rewrite rules for Acme case studies.', 'acme' )
);
} );
// Useful $screen properties while debugging:
// $screen->id 'edit-post', 'options-permalink', 'toplevel_page_acme'
// $screen->base 'edit', 'post', 'options-general'
// $screen->post_type 'post', 'page', 'acme_case_study'Settings screens are just options rows
Connects the screens a client edits to the values your code reads. The blog_public check is worth shipping on every project: a site launched with the staging 'discourage search engines' box still ticked is the most expensive checkbox in WordPress.
<?php
// Everything under Settings writes to the wp_options table. Knowing the
// option names lets you read the same values your queries already inherit.
$per_page = (int) get_option( 'posts_per_page' ); // Settings > Reading
$front = (int) get_option( 'page_on_front' ); // Settings > Reading
$show_on = get_option( 'show_on_front' ); // 'posts' or 'page'
$structure = get_option( 'permalink_structure' ); // Settings > Permalinks
$tz = get_option( 'timezone_string' ); // Settings > General
$name = get_option( 'blogname' ); // Settings > General
$public = (int) get_option( 'blog_public' ); // Reading > search engines
// Same values from the command line, no browser needed:
// wp option get permalink_structure
// wp option update posts_per_page 12
// Warn if the site is still hidden from search engines after go-live.
add_action( 'admin_notices', static function () {
if ( '0' !== (string) get_option( 'blog_public' ) || ! current_user_can( 'manage_options' ) ) {
return;
}
printf(
'<div class="notice notice-warning"><p>%s</p></div>',
esc_html__( 'Search engine indexing is discouraged in Settings > Reading.', 'acme' )
);
} );
Discussion