What the WordPress administration panel consists of
The WordPress administration panel looks like one application, but technically it is a set of plain PHP files in the wp-admin directory, tied together by one bootstrap file and a few dozen actions. Developers who know the order in which those actions run write plugins that load scripts only where they are needed and do not break the screens of other plugins.
This article covers the wp-admin directory, the path of a single request through the panel, how screens are named ($hook_suffix, WP_Screen) and how the panel differs from the theme template hierarchy. File names and action order come from the WordPress 7.1.3 code (the current stable release at the time of checking) in the WordPress/WordPress mirror on GitHub.
What is the wp-admin directory structure in WordPress
The wp-admin directory holds the back-end files of the site. The top level has one file per screen: edit.php is the post list, post.php and post-new.php handle editing, upload.php is the Media Library, options-general.php, options-writing.php and the other options-*.php files are the Settings subpages, and site-health.php is Site Health. The address in the browser bar therefore tells you which file is running. Next to them sit the subdirectories network/ (the network admin in multisite), user/ (the user admin in multisite) and maint/ (database repair).
Core files in the wp-admin directory
admin.php - The bootstrap file of the panel. Every screen starts with require_once __DIR__ . '/admin.php';. This is where:
- the
WP_ADMINconstant is defined andwp-load.phpis loaded - the functions from
wp-admin/includes/admin.phpare included auth_redirect()sends logged-out visitors to the login screen- the menu is built and the current screen is set
index.php - The Dashboard, the landing page of the panel. It shows:
- dashboard widgets
- quick actions
- WordPress news
menu.php - The definition of the panel menu structure:
- registration of menu items
- the capabilities required for each section
- menu icons and labels
Key subdirectories in wp-admin
includes/ - Holds all the library files:
class-wp-screen.php- the class that manages panel screenstemplate.php- helper functions for templatesmedia.php- media handlingajax-actions.php- AJAX handlers
css/ - The panel stylesheets:
wp-admin.css- the aggregate stylesheet that imports the otherscommon.css- shared styles (the.wrapcontainer,.noticenotices)colors/- color schemesforms.css- form styles
Each stylesheet ships in four variants: the source file, a minified one (.min.css) and two versions for right-to-left languages (-rtl.css, -rtl.min.css). The same applies to the scripts in js/: common.min.js sits next to common.js. In production WordPress serves the .min versions, and the SCRIPT_DEBUG constant switches it to the source files, as described below.
js/ - JavaScript files:
common.js- shared behaviorpost.js- the post editing screenmedia-upload.js- media upload
What WordPress does when you open a wp-admin address
Take the request wp-admin/admin.php?page=my-page, a screen added by a plugin. In version 7.1.3 admin.php goes through these steps in order:
- It defines
WP_ADMINand includeswp-load.php, and with itwp-config.php, the plugins and the theme. From this pointis_admin()returnstrue. - It includes
wp-admin/includes/admin.php, the whole panel API, and callsauth_redirect(). - It includes
wp-admin/menu.php. That file builds the$menuand$submenuarrays with the core items and finally includeswp-admin/includes/menu.php, which fires theadmin_menuaction. This is where plugins calladd_menu_page(). - It fires the
admin_initaction. - From the
pageparameter it computes the page hook name (get_plugin_page_hook()) and stores it in the global$hook_suffixvariable. - It calls
set_current_screen(), which creates theWP_Screenobject for this screen. - It fires the
load-{$page_hook}action, before any HTML has been sent. - It includes
admin-header.php, unless the address contains thenoheaderparameter. - It fires the action named
$page_hook. That is whereadd_menu_page()attached the plugin callback earlier. - It includes
admin-footer.phpand ends withexit.
Two consequences of this order break plugins in practice. First, admin_menu runs before admin_init, not after it, so menu registration code cannot assume that something set up in admin_init already exists. Second, get_current_screen() returns null until step 6, because the function returns the global object only when it is an instance of WP_Screen. Calling it in admin_init gives null, while in load-{$page_hook} it already returns a working object.
The load-{$page_hook} action is the best place to process a form that redirects after saving. HTTP headers have not been sent yet, so wp_safe_redirect() works, and the code runs on that one screen only. A comment in admin.php gives an example name: for a plugin page added under Settings the hook is called load-settings_page_pluginbasename.
What runs in admin-header.php and admin-footer.php
The panel header is also a sequence of actions, and their order decides where styles and scripts should be attached. In admin-header.php WordPress fires, in order:
admin_enqueue_scriptswith the$hook_suffixargument, the place forwp_enqueue_script()andwp_enqueue_style(),admin_print_styles-{$hook_suffix}andadmin_print_styles,admin_print_scripts-{$hook_suffix}andadmin_print_scripts,admin_head-{$hook_suffix}andadmin_head,- then it includes
menu-header.php, which draws the side menu, and firesin_admin_header, - finally
admin_notices(ornetwork_admin_noticesanduser_admin_noticesin multisite) andall_admin_notices.
In admin-footer.php the order is similar: in_admin_footer, admin_footer, admin_print_footer_scripts-{$hook_suffix}, admin_print_footer_scripts and admin_footer-{$hook_suffix}. The suffixed versions run on one screen only, so they replace an if condition inside the callback. The documentation of the admin_enqueue_scripts hook calls it the proper hook for enqueuing scripts and styles meant for the admin (despite the name it handles both) and recommends using $hook_suffix to load them only on the screens that use them.
Core screens such as options-general.php do the same without a page parameter: they start with require_once __DIR__ . '/admin.php';, prepare their data, include admin-header.php themselves, print the form and include admin-footer.php.
How the administration template system works
WordPress does not use a traditional template system in the administration panel like the one on the front end. Instead it relies on a structure built from callback functions and hooks.
Structure of an administration screen
Every panel screen consists of three main parts. The sketch below shows the layout of a core screen, which includes the header and footer itself. A plugin page callback does not do this, because admin.php includes the header and footer for it (steps 8 and 10 above). admin.php includes the header with require_once, so a second require_once in the callback does nothing, but a plain require or include prints the panel header a second time.
// Example structure of a core screen, e.g. wp-admin/options-general.php
require_once __DIR__ . '/admin.php';
// 1. Header
require_once ABSPATH . 'wp-admin/admin-header.php';
// 2. Page content
?>
<div class="wrap">
<h1><?php echo esc_html(get_admin_page_title()); ?></h1>
<!-- The actual content goes here -->
</div>
<?php
// 3. Footer
require_once ABSPATH . 'wp-admin/admin-footer.php';Callback functions for administration pages
Every panel page has a callback function assigned to it that renders its content:
// Register a panel page
add_action('admin_menu', function() {
add_menu_page(
'My Page', // Page title
'My Page', // Menu label
'manage_options', // Capability
'my-page', // Slug
'my_page_callback', // Callback function
'dashicons-admin-generic',
30
);
});
// Callback function that renders the content
function my_page_callback() {
?>
<div class="wrap">
<h1><?php echo esc_html(get_admin_page_title()); ?></h1>
<form method="post" action="options.php">
<?php
settings_fields('my_options_group');
do_settings_sections('my-page');
submit_button('Save Changes');
?>
</form>
</div>
<?php
}How WordPress names the hook_suffix of plugin pages
The value returned by add_menu_page() and add_submenu_page() is the $hook_suffix of the new screen. The same string goes to admin_enqueue_scripts, to the load-* hooks and to WP_Screen, so it pays to know where it comes from. It is computed by get_plugin_page_hookname() in wp-admin/includes/plugin.php following the pattern {type}_page_{slug}:
- a top-level page added with
add_menu_page()gets the typetoplevel, sotoplevel_page_my-page, - a subpage under a core menu gets a type derived from the parent file name, for example
settings_page_my-pageunder Settings, - a subpage under another plugin’s menu gets its type from the
$admin_page_hooksarray, whereadd_menu_page()storessanitize_title( $menu_title ).
The last point is a trap on translated sites. If the parent menu title goes through __(), then changing the site language also changes the suffix of all its subpages. A menu titled __( 'Reports' ) gives its subpage the suffix reports_page_my-reports, but once the plugin’s translation turns the title into “Raporty” on a Polish panel, the suffix becomes raporty_page_my-reports. Code that compares $hook with the first value hard-coded during development stops working. It is safer to store the value returned by add_submenu_page() in a class property and compare against it, or to check get_current_screen()->id.
What WP_Screen and get_current_screen are for
The WP_Screen object describes the current panel screen. Its public properties include id, base, parent_base, post_type, taxonomy and action. For the page list id is edit-page, base is edit and post_type is page, so one condition on post_type covers the list, the editor and the add-new screen of a given type.
The object also carries two interface elements that are often written from scratch for no reason: help tabs (add_help_tab()) and screen options (add_option(), for example the number of items per page in a list). Both should be added in the load-{$page_hook} action, because only then does the screen object exist while the header with the “Help” and “Screen Options” buttons has not been printed yet.
Which body classes an admin screen gets
admin-header.php builds the class attribute of the body tag from several sources. The first class is $hook_suffix with every character outside a-z0-9_- replaced by a hyphen, so a plugin screen gets the class toplevel_page_my-page on body. Then come, among others, post-type-{type} and taxonomy-{taxonomy} from the screen object, admin-color-{scheme} from the user’s profile settings, locale-{language}, branch-{version} and version-{version}, multisite and network-admin on a network, and wp-theme-{theme} with the name of the active theme. Custom classes are added with the admin_body_class filter. This lets plugin CSS target a single screen with a class selector instead of loading a separate stylesheet.
How hooks work in the WordPress administration panel
Hooks are the mechanism that changes the behavior of the panel without touching WordPress core files.
Action hooks in the administration panel
Actions run code at a specific moment:
// Add a script only in the administration panel
add_action('admin_enqueue_scripts', function($hook) {
// $hook holds the identifier of the current page
if ($hook === 'toplevel_page_my-page') {
wp_enqueue_script(
'my-page-js',
plugin_dir_url(__FILE__) . 'js/admin.js',
['jquery'],
'1.0.0',
true
);
}
});
// Modify the admin head
add_action('admin_head', function() {
?>
<style>
#wpadminbar { background: #1d2327; }
</style>
<?php
});
// Add a notice
add_action('admin_notices', function() {
?>
<div class="notice notice-success is-dismissible">
<p>Your changes have been saved.</p>
</div>
<?php
});Filter hooks in the administration panel
Filters change data before it is displayed or saved:
// Modify the panel page title
add_filter('admin_title', function($admin_title, $title) {
return $title . ' | My Site';
}, 10, 2);
// Change the panel footer text
add_filter('admin_footer_text', function($text) {
return 'Thank you for creating with <a href="https://wordpress.org">WordPress</a> | Technical support: My Company';
});
// Modify the columns of the post list
add_filter('manage_posts_columns', function($columns) {
$new_columns = [];
foreach ($columns as $key => $value) {
$new_columns[$key] = $value;
if ($key === 'title') {
$new_columns['my_column'] = 'My Extra Column';
}
}
return $new_columns;
});Which CSS classes the WordPress administration panel uses
WordPress ships a set of CSS classes that keeps the panel looking consistent.
Container CSS classes in the panel
/* Main page container */
.wrap {
margin: 10px 20px 0 2px;
}
/* Page heading */
.wrap h1 {
font-size: 23px;
font-weight: 400;
margin: 0;
padding: 9px 0 4px;
line-height: 1.3;
}
/* Metaboxes */
.postbox {
min-width: 255px;
border: 1px solid #c3c4c7;
box-shadow: 0 1px 1px rgba(0,0,0,.04);
background: #fff;
}
/* Forms */
.form-table {
border-collapse: collapse;
margin-top: 0.5em;
width: 100%;
clear: both;
}
.form-table th {
vertical-align: top;
text-align: left;
padding: 20px 10px 20px 0;
width: 200px;
line-height: 1.3;
font-weight: 600;
}Button and UI component CSS classes
/* Buttons (wp-includes/css/buttons.css) */
.wp-core-ui .button-primary {
background: var(--wp-admin-theme-color, #3858e9);
border-color: var(--wp-admin-theme-color, #3858e9);
color: #fff;
text-decoration: none;
text-shadow: none;
}
/* Notices (wp-admin/css/common.css) */
.notice {
background: #fff;
border: none;
border-left: 4px solid #c3c4c7;
box-shadow: none;
margin: 5px 15px 2px;
padding: 8px 12px;
}
.notice-success {
border-left-color: #4ab866;
}
.notice-error {
border-left-color: #cc1818;
}The values above come from the buttons.css and common.css files in version 7.1.3. The primary button color is not hard-coded: it comes from the --wp-admin-theme-color CSS variable, which the selected color scheme sets, and #3858e9 is only the fallback value. A plugin that hard-codes the button color in its own stylesheet will clash with the panel of a user who picked a different scheme in their profile. It is safer to use the core classes (button button-primary, notice notice-success) and leave their colors alone.
How to build custom administration screens
Below is the shortest working example of an options page built on the Settings API. A full guide to tidying menus, client roles, color schemes and dashboard widgets is in a separate article on WordPress admin customization.
How to add a simple options page
class My_Options_Page {
public function __construct() {
add_action('admin_menu', [$this, 'add_page']);
add_action('admin_init', [$this, 'register_settings']);
}
public function add_page() {
add_options_page(
'My Settings',
'My Settings',
'manage_options',
'my-settings',
[$this, 'render_page']
);
}
public function register_settings() {
register_setting('my_settings_group', 'my_option_1');
register_setting('my_settings_group', 'my_option_2');
add_settings_section(
'my_section',
'Main Settings',
[$this, 'render_section'],
'my-settings'
);
add_settings_field(
'my_option_1',
'First Option',
[$this, 'render_field_1'],
'my-settings',
'my_section'
);
}
public function render_page() {
?>
<div class="wrap">
<h1><?php echo esc_html(get_admin_page_title()); ?></h1>
<form action="options.php" method="post">
<?php
settings_fields('my_settings_group');
do_settings_sections('my-settings');
submit_button('Save Settings');
?>
</form>
</div>
<?php
}
public function render_field_1() {
$value = get_option('my_option_1');
?>
<input type="text" name="my_option_1" value="<?php echo esc_attr($value); ?>" class="regular-text">
<?php
}
}
new My_Options_Page();What is the difference between admin-ajax.php and admin-post.php
The panel has two separate entry points for requests that do not render a screen, and both bypass admin.php.
admin-ajax.php handles AJAX requests. It defines the DOING_AJAX constant, loads wp-load.php and the panel API, sends the X-Robots-Tag: noindex header, fires admin_init, and then the wp_ajax_{action} action for logged-in users or wp_ajax_nopriv_{action} for guests. A missing action parameter ends with the response 0 and status 400, and a missing registered handler also ends with 0 and status 400. On panel screens the address of this file is available right away in the JavaScript variable ajaxurl, which admin-header.php prints. The Plugin Handbook describes the whole flow in the chapter on AJAX in plugins.
admin-post.php is meant for forms sent with a plain POST that should be followed by a redirect. It fires admin_init and then admin_post_{action} for logged-in users or admin_post_nopriv_{action} for guests. If nothing is attached to the action, the file ends with an empty response and status 400. The hook is described on the admin_post_{$action} page.
What the two files share has performance consequences. admin_init runs on every request to admin-ajax.php, including the periodic Heartbeat API requests (the heartbeat action is on the list of core actions, and wp_ajax_nopriv_heartbeat covers guests). Heavy code attached to admin_init without a wp_doing_ajax() check therefore runs not only when a screen opens but on every such tick.
How the panel differs from the theme template hierarchy
The word “template” in the title of this article is easy to confuse with theme templates. The front end picks a file according to the template hierarchy, which the Theme Handbook describes like this:
“When a visitor lands on any page on a WordPress site, WordPress uses the query string to decide which template should be used to display the page. The query string contains data from the URL that helps make this determination.”
For block themes the same documentation page lists three places where WordPress looks for a template, in order of priority: user-created templates stored in the database as the wp_template post type, the child theme’s /templates folder and the parent theme’s /templates folder.
The panel has none of these mechanisms. There is no fallback file like a theme’s index.php and no matching by query type. What gets displayed depends on the file name in wp-admin or the page parameter, and the screen layout is always the same header, menu and footer from core. The theme has no say over how the panel looks, apart from its name ending up in the wp-theme-* class on body. That is why panel styles and scripts are enqueued through admin_enqueue_scripts and front-end ones through wp_enqueue_scripts. Mixing up the two actions ends with the admin stylesheet on the front end or the other way round.
How to secure WordPress administration screens
Checking user capabilities
Always verify user capabilities:
// Check that the user has the right capabilities
if (!current_user_can('manage_options')) {
wp_die(__('You do not have permission to access this page.', 'text-domain'));
}
// Check the nonce for the action
if (!isset($_POST['my_nonce']) || !wp_verify_nonce($_POST['my_nonce'], 'my_action')) {
wp_die(__('Invalid security token.', 'text-domain'));
}Sanitizing panel form data
// Text sanitization
$title = sanitize_text_field($_POST['title']);
// HTML sanitization
$content = wp_kses_post($_POST['content']);
// URL sanitization
$url = esc_url_raw($_POST['url']);
// Email sanitization
$email = sanitize_email($_POST['email']);How to debug the WordPress administration dashboard
How to enable WP_DEBUG in the panel
// In wp-config.php
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', false);
define('SCRIPT_DEBUG', true); // Uses unminified scriptsThe Debugging in WordPress documentation describes SCRIPT_DEBUG as a constant that makes WordPress use the development versions of core CSS and JavaScript files instead of the minified ones, and points to it as useful when testing changes to the built-in .js and .css files. It defaults to false. In panel terms this means the browser gets common.js instead of wp-admin/js/common.min.js, and a stack trace from the console points to a readable line instead of one long line of the .min file. The same page reminds you that the definitions have to go before the comment that ends editing in wp-config.php.
Logging panel events to debug.log
function log_admin_action($action, $data) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log("Admin Action [{$action}]: " . print_r($data, true));
}
}
// Usage
log_admin_action('user_login', ['user_id' => get_current_user_id()]);What to remember about the WordPress administration panel
Five points from this anatomy that matter when writing a plugin:
- Directory structure: knowing how the files are organized makes debugging and changes faster.
- Template system: callback functions and hooks control how a screen renders.
- Security: capability checks and data sanitization are the baseline.
- CSS classes: staying consistent with core classes keeps plugin screens in line with the rest of the panel.
- Debugging:
SCRIPT_DEBUGand the debug log shorten the path to the cause of an error.
Panel modifications survive updates only when they use the built-in mechanisms instead of working around them. We prepare and test such changes on staging as part of WordPress customization.







