How to create an admin page with its own menu in PHP in WordPress

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:

  1. Hook a function to admin_menu to call add_menu_page() and add_submenu_page() as needed.
  2. Provide a callback to render the page HTML.
  3. Register/enqueue any scripts and styles for that admin page only.
  4. 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.

add_menu_page() parameters

  • 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).

Simple plugin example: add a top-level menu and page

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.

Adding submenus and controlling the default first submenu

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,  )
    ?>
    

class=regular-text>

>

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.

Icons and menu position tips

  • 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 ) .

} public function ajax_handler() { if ( ! current_user_can( manage_options ) ) { wp_send_json_error( array( message => You are not allowed. ), 403 ) } check_ajax_referer( cap_admin_nonce, nonce ) response = array( message => AJAX success ) wp_send_json_success( response ) } } new Complete_Admin_Page()

Common pitfalls and how to avoid them

  1. Menu duplication: When add_menu_page() automatically adds a submenu with the same slug. Fix: add a submenu with the same slug explicitly to control the first submenu label.
  2. Loading assets site-wide: Always check the page hook (hook_suffix) before enqueuing scripts/styles.
  3. No capability checks: Failing to check current_user_can() exposes admin functionality to unwanted users. Always check.
  4. Missing nonce checks: Without nonces, your pages are vulnerable to CSRF. Use wp_nonce_field() and check_admin_referer() or check_ajax_referer() for AJAX.
  5. Saving unchecked input: Always sanitize inputs before saving to the DB.

Testing and debugging tips

Additional resources

Final notes

This article covered everything needed to create a robust WordPress admin page with its own top-level menu in PHP, including examples for simple and full-featured implementations. Use the Settings API for options pages, sanitize inputs, verify nonces, and enqueue assets only on the relevant admin pages. The complete plugin example ties together these best practices into a ready-to-use pattern you can adapt.



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *