Contents
Overview
This article explains, in exhaustive detail, how to create a WordPress admin page with its own top-level menu in PHP. It covers the simple add_menu_page() usage, submenu handling, enqueuing scripts/styles for that page, building secure forms (both using the Settings API and manual handling), AJAX from the admin page, capability checks, sanitization, internationalization, multisite considerations, icons, and practical tips to avoid common pitfalls.
Prerequisites and recommended setup
- Environment: a working WordPress installation and access to install or upload a plugin or edit a themes functions.php (plugin is recommended).
- PHP version: WordPress minimum supported PHP version newer PHP features (closures, namespacing) are fine if your environment supports them.
- Where to add code: Prefer creating a plugin file (recommended). Editing functions.php works but is less portable.
Basic principles
To add an admin page with a top-level menu, the usual flow is:
- Hook a function to admin_menu to call add_menu_page() and add_submenu_page() as needed.
- Provide a callback to render the page HTML.
- Register/enqueue any scripts and styles for that admin page only.
- Secure input processing (capability checks, nonces, sanitization) and save options with update_option() or the Settings API.
Core functions and parameters (quick reference)
add_menu_page() | Creates a top-level admin menu and returns the hook_suffix for the page. |
add_submenu_page() | Adds a submenu under a parent menu slug. |
admin_menu | Action hook used to register menus. |
admin_enqueue_scripts | Hook to enqueue scripts/styles for admin pages (filter by page hook). |
register_setting/add_settings_section/add_settings_field | Settings API helpers to register options and build forms. |
- page_title — Title for the page (shown in lttitlegt and top of page).
- menu_title — Label shown in the admin menu.
- capability — Capability required to see the menu (e.g., manage_options).
- menu_slug — Unique slug for the menu page (used in URL as page=menu_slug).
- callback — Function that outputs the page content.
- icon_url — Dashicon class like dashicons-admin-generic or a full URL to an icon.
- position — Position in the admin menu (number, optional).
Create a plugin file (e.g., my-admin-page.php) and place the following code. This shows the minimal structure.
Notes about the sample
- We used a closure for admin_enqueue_scripts that checks the hook suffix. This ensures scripts/css are loaded only for the plugin page.
- get_admin_page_title() pulls the page title you registered.
- Always check current_user_can() before outputting privileged content.
When you call add_menu_page(), WordPress automatically adds a first submenu item that points to the same page. If you want to control submenu labels or add multiple submenus, use add_submenu_page(). To avoid a duplicate-looking menu item, explicitly add the parent slug as the first submenu with the same slug as the parent.
add_action( admin_menu, my_plugin_menus ) function my_plugin_menus() { parent_slug = my-plugin-slug add_menu_page( Parent Page, Parent Menu, manage_options, parent_slug, parent_page_callback, dashicons-admin-site ) // Replace the automatically added first submenu with a nicer label: add_submenu_page( parent_slug, Main Settings, Settings, manage_options, parent_slug, parent_page_callback ) // Add another submenu page add_submenu_page( parent_slug, Other Tools, Tools, manage_options, my-plugin-tools, tools_page_callback ) }
Enqueue assets only on your plugin page
Loading scripts and styles site-wide significantly burns resources. Use the hook_suffix returned by add_menu_page() or test the current screen to enqueue only on your page.
// Using the returned hook suffix hook = add_menu_page( / args / ) add_action( admin_enqueue_scripts, my_plugin_enqueue ) function my_plugin_enqueue( hook_suffix ) { global hook // OR capture via closure if ( hook_suffix !== hook ) { return } wp_enqueue_script( my-plugin-script ) }
Secure form handling: using check_admin_referer and capability checks
If you process POST data manually (not using the Settings API), you must check the users capabilities and verify a nonce before saving data.
function my_plugin_render_admin_page() { if ( ! current_user_can( manage_options ) ) { return } // Handle save if ( isset( _POST[my_plugin_submit] ) ) { check_admin_referer( my_plugin_save_action, my_plugin_nonce_field ) value = isset( _POST[my_plugin_field] ) ? sanitize_text_field( wp_unslash( _POST[my_plugin_field] ) ) : update_option( my_plugin_option, value ) // Redirect to avoid resubmission (optional) wp_safe_redirect( add_query_arg( updated, true, menu_page_url( my-plugin-slug, false ) ) ) exit } saved = get_option( my_plugin_option, ) ?>Using the Settings API (recommended for options)
The Settings API helps register settings, sections, and fields, handles sanitization callbacks and integrates nicely with WordPress settings forms. Here is a complete example.
add_action( admin_init, my_plugin_settings_init ) function my_plugin_settings_init() { register_setting( my_plugin_options_group, my_plugin_options, my_plugin_sanitize_options ) add_settings_section( my_plugin_main_section, __( Main Settings, my-plugin-textdomain ), my_plugin_section_cb, my_plugin ) add_settings_field( my_plugin_text_field, __( Text Option, my-plugin-textdomain ), my_plugin_text_field_cb, my_plugin, my_plugin_main_section, array( label_for => my_plugin_text_field ) ) } function my_plugin_section_cb() { echo. esc_html__( Configure the main settings below:, my-plugin-textdomain ) .
} function my_plugin_text_field_cb( args ) { options = get_option( my_plugin_options, array() ) value = isset( options[text_field] ) ? options[text_field] : printf( , esc_attr( args[label_for] ), esc_attr( text_field ), esc_attr( value ) ) } function my_plugin_sanitize_options( input ) { output = array() if ( isset( input[text_field] ) ) { output[text_field] = sanitize_text_field( input[text_field] ) } return output } // In your admin page callback, output the settings form: function my_plugin_render_settings_page() { if ( ! current_user_can( manage_options ) ) { return } ?>AJAX interactions from the admin page
If you want asynchronous operations, use admin-ajax.php and the wp_ajax_{action} hook. Remember to check capabilities and verify a nonce.
// Server-side: register handler add_action( wp_ajax_my_plugin_do_something, my_plugin_do_something_ajax ) function my_plugin_do_something_ajax() { if ( ! current_user_can( manage_options ) ) { wp_send_json_error( array( message => Forbidden ), 403 ) } check_ajax_referer( my_plugin_ajax_nonce, nonce ) data = isset( _POST[data] ) ? sanitize_text_field( wp_unslash( _POST[data] ) ) : // ... do something with data ... wp_send_json_success( array( result => Processed: . data ) ) } // Client-side JS (enqueue this script for your admin page)jQuery( function( ) { ( #my-plugin-action-button ).on( click, function( e ) { e.preventDefault() var data = { action: my_plugin_do_something, nonce: myPlugin.ajax_nonce, data: hello from admin } .post( myPlugin.ajax_url, data, function( response ) { if ( response.success ) { alert( response.data.result ) } else { alert( Error: ( response.data response.data.message ? response.data.message : unknown ) ) } } ) } ) } )// When enqueuing the script on the admin page, localize required variables: wp_enqueue_script( my-plugin-admin-js, plugin_dir_url( __FILE__ ) . assets/admin.js, array( jquery ), 1.0, true ) wp_localize_script( my-plugin-admin-js, myPlugin, array( ajax_url => admin_url( admin-ajax.php ), ajax_nonce => wp_create_nonce( my_plugin_ajax_nonce ), ) )Internationalization (i18n)
Wrap user-visible strings with translation functions:
- __(), _e(), _n(), esc_html__(), esc_html_e(), etc.
- Load plugin textdomain using load_plugin_textdomain() or use modern methods (registering in init).
Security best practices checklist
- Always check current_user_can() for capability required to access the page or save options.
- Use nonces: wp_nonce_field() check_admin_referer() for standard forms check_ajax_referer() for AJAX.
- Sanitize all input (sanitize_text_field, sanitize_email, sanitize_textarea_field, absint, esc_url_raw, etc.) before saving.
- Escape output: esc_html(), esc_attr(), wp_kses_post() when printing on admin page.
- Load assets only on required pages.
- Prefer the Settings API when storing options it standardizes sanitization and settings pages.
Multisite and network admin considerations
- On multisite, use network_admin_menu to add menus to the Network Admin screens.
- Use is_multisite() and is_network_admin() to decide whether to show network vs site menus.
- Options are per-site by default for network-wide options use update_site_option() and get_site_option() when in network context.
- Use Dashicons (e.g., dashicons-admin-generic) for a simple icon: pass the dashicon class as the icon_url parameter.
- Use a full URL to a custom icon image (SVG/PNG) if you need branding. Provide 20x20 (or 16x16) images for consistency.
- menu position is numeric common ranges: 2-59 (top menus like Dashboard), 60-99 (below settings). Avoid collisions.
Complete, production-ready plugin example
This example bundles everything: top-level menu, settings page via the Settings API, enqueued admin assets specifically for the page, AJAX handler, and safe saving with sanitization and capability checking.
menu_hook = add_menu_page( __( Complete Admin, complete-admin-page ), __( Complete Admin, complete-admin-page ), manage_options, complete-admin, array( this, render_page ), dashicons-admin-tools, 66 ) // Add submenus first submenu replaces the default label add_submenu_page( complete-admin, __( Settings, complete-admin-page ), __( Settings, complete-admin-page ), manage_options, complete-admin, array( this, render_page ) ) add_submenu_page( complete-admin, __( Tools, complete-admin-page ), __( Tools, complete-admin-page ), manage_options, complete-admin-tools, array( this, render_tools ) ) add_action( admin_enqueue_scripts, array( this, enqueue_admin_assets ) ) } public function enqueue_admin_assets( hook ) { if ( hook !== this->menu_hook hook !== toplevel_page_complete-admin ) { return } wp_enqueue_style( cap-admin-css, plugin_dir_url( __FILE__ ) . assets/admin.css, array(), 1.0 ) wp_enqueue_script( cap-admin-js, plugin_dir_url( __FILE__ ) . assets/admin.js, array( jquery ), 1.0, true ) wp_localize_script( cap-admin-js, capAdmin, array( ajax_url => admin_url( admin-ajax.php ), ajax_nonce => wp_create_nonce( cap_admin_nonce ), ) ) } public function settings_init() { register_setting( cap_options_group, cap_options, array( this, sanitize_options ) ) add_settings_section( cap_main_section, __( Main Settings, complete-admin-page ), array( this, section_cb ), cap_settings_page ) add_settings_field( cap_text_field, __( Text Option, complete-admin-page ), array( this, text_field_cb ), cap_settings_page, cap_main_section, array( label_for => cap_text_field ) ) } public function section_cb() { echo. esc_html__( Configure plugin main settings here., complete-admin-page ) .
} public function text_field_cb( args ) { options = get_option( cap_options, array() ) value = isset( options[text_field] ) ? options[text_field] : printf( , esc_attr( text_field ), esc_attr( value ) ) } public function sanitize_options( input ) { output = array() if ( isset( input[text_field] ) ) { output[text_field] = sanitize_text_field( input[text_field] ) } return output } public function render_page() { if ( ! current_user_can( manage_options ) ) { return } ?>. esc_html__( Tools, complete-admin-page ) .