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.

Syntaxadd_action( 'admin_menu', 'callback' ); // menus exist only inside this hook

The 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.

MenuFileCapability
Dashboardindex.phpread
Postsedit.phpedit_posts
Pagesedit.php?post_type=pageedit_pages
Mediaupload.phpupload_files
Appearancethemes.phpswitch_themes, else edit_theme_options
Pluginsplugins.phpactivate_plugins
Usersusers.phplist_users
Toolstools.phpedit_posts
Settingsoptions-general.phpmanage_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

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

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

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

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

  • Be the first to comment on this lesson.