How to create a custom dashboard widget in admin with PHP in WordPress

Contents

Introduction

What this tutorial covers: a complete, detailed walkthrough for creating a custom WordPress admin dashboard widget with PHP. You will learn basic and advanced approaches: a minimal widget, a widget with settings saved via a form (admin-post), an AJAX-based save, enqueuing admin assets, security and capability checks, localization, removing or repositioning widgets, and common debugging tips. All code examples are ready to drop into a plugin file or adapt to your themes admin code.

Prerequisites and environment

  • WordPress (4.7 recommended, but code works with modern 5.x installs).
  • Administrator account for testing (widgets are typically admin-only).
  • Basic PHP knowledge and familiarity with writing small plugins or adding admin code.
  • Optional: access to create a plugin file in wp-content/plugins for clean separation.

Plugin structure and best practice

Prefer packaging dashboard widgets into a small plugin rather than placing logic in theme functions.php. This keeps admin UI consistent across theme changes and is easier to maintain. Essential elements of a plugin that contains a dashboard widget:

  • Plugin header block.
  • Hooking into wp_dashboard_setup to register widgets.
  • Callback(s) that output the widget contents (and optionally a form).
  • Handlers for form submissions — use admin-post.php or AJAX endpoints.
  • Security: capability checks, nonces, sanitization, escaping.

Minimal dashboard widget (basic example)

This minimal plugin registers a widget and outputs static content. Save as a PHP file in wp-content/plugins (example: dashboard-widget-basic.php), then activate the plugin.

lt?php
/
  Plugin Name: Dashboard Widget - Basic Example
  Description: Adds a simple custom dashboard widget.
  Version: 1.0
  Author: Your Name
  Text Domain: dw-basic
 /

add_action(wp_dashboard_setup, dw_basic_register_widget)

function dw_basic_register_widget() {
    wp_add_dashboard_widget(
        dw_basic_widget, // Widget slug/ID
        __(My Simple Widget, dw-basic), // Title
        dw_basic_display // Display callback
    )
}

function dw_basic_display() {
    // Always escape output appropriately
    echo ltpgt . esc_html__(Hello! This is a simple dashboard widget., dw-basic) . lt/pgt
}

How it works

  1. wp_dashboard_setup action runs during admin initialization for dashboard meta boxes.
  2. wp_add_dashboard_widget registers the widget. You provide an ID, title, and callback.
  3. The callback prints the widget contents. Use escaping functions like esc_html or wp_kses_post.

Widget with persistent settings (form admin-post handler)

If you want the widget to include a small settings form (example: text or HTML saved in options), use admin-post.php with an action and handler registered via admin_post_{action}. This pattern avoids processing POST inside output callbacks and handles redirects properly.

lt?php
/
  Plugin Name: Dashboard Widget with Settings
  Description: Dashboard widget with a settings form saved via admin-post.
  Version: 1.0
  Author: Your Name
  Text Domain: dw-settings
 /

add_action(wp_dashboard_setup, dw_settings_register_widget)
add_action(admin_post_dw_save_widget, dw_save_widget_handler) // Handles the form POST

function dw_settings_register_widget() {
    wp_add_dashboard_widget(
        dw_settings_widget,
        __(Custom Notes, dw-settings),
        dw_settings_display
    )
}

function dw_settings_display() {
    // Only show to users who can manage options (example capability)
    if (!current_user_can(manage_options)) {
        echo ltpgt . esc_html__(You do not have permission to view this widget., dw-settings) . lt/pgt
        return
    }

    content = get_option(dw_widget_content, )

    // The form posts to admin-post.php?action=dw_save_widget
    echo ltform method=post action= . esc_url(admin_url(admin-post.php)) . gt
    echo ltinput type=hidden name=action value=dw_save_widget /gt
    wp_nonce_field(dw_save_widget_action, dw_save_widget_nonce) // nonce for security
    echo ltpgt
    echo ltlabel for=dw_widget_contentgt . esc_html__(Notes (HTML allowed):, dw-settings) . lt/labelgtltbr/gt
    echo lttextarea id=dw_widget_content name=dw_widget_content rows=6 cols=30gt . esc_textarea(content) . lt/textareagt
    echo lt/pgt
    echo ltpgtltinput type=submit class=button button-primary value= . esc_attr__(Save, dw-settings) .  /gtlt/pgt
    echo lt/formgt
}

function dw_save_widget_handler() {
    // Capability check
    if (!current_user_can(manage_options)) {
        wp_die(__(Unauthorized, dw-settings))
    }

    // Nonce check
    check_admin_referer(dw_save_widget_action, dw_save_widget_nonce)

    // Sanitize: allow safe HTML via wp_kses_post
    content = isset(_POST[dw_widget_content]) ? wp_kses_post(trim(_POST[dw_widget_content])) : 

    // Save option
    update_option(dw_widget_content, content)

    // Redirect back to the previous page (dashboard)
    redirect = wp_get_referer() ? wp_get_referer() : admin_url()
    wp_safe_redirect(redirect)
    exit
}

Notes on this approach

  • Use wp_nonce_field in the form and check_admin_referer in the handler.
  • Use proper capability checks (e.g., manage_options, or a capability appropriate for your widget).
  • Sanitize saved content. For rich text saved from trusted users you can use wp_kses_post. For plain text use sanitize_text_field or esc_textarea on output.
  • Redirect after saving to avoid double POST on refresh.

AJAX-based saving (better UX)

AJAX lets you save widget data without a full page reload. Use the wp_ajax_ action for logged-in AJAX requests and create a small admin script. Below are the essential plugin PHP and a matching JS snippet.

lt?php
/
  Plugin Name: Dashboard Widget AJAX Save
  Description: Adds a dashboard widget that saves via AJAX.
  Version: 1.0
  Author: Your Name
  Text Domain: dw-ajax
 /

add_action(wp_dashboard_setup, dw_ajax_register_widget)
add_action(admin_enqueue_scripts, dw_ajax_admin_assets) // enqueue script on admin
add_action(wp_ajax_dw_save_ajax, dw_ajax_save_handler) // AJAX handler

function dw_ajax_register_widget() {
    wp_add_dashboard_widget(dw_ajax_widget, __(AJAX Widget, dw-ajax), dw_ajax_display)
}

function dw_ajax_display() {
    if (!current_user_can(manage_options)) {
        echo ltpgt . esc_html__(No permission, dw-ajax) . lt/pgt
        return
    }

    content = get_option(dw_ajax_content, )
    echo lttextarea id=dw-ajax-content rows=5 cols=35gt . esc_textarea(content) . lt/textareagtnbsp
    echo ltbutton id=dw-ajax-save class=buttongt . esc_html__(Save via AJAX,dw-ajax) . lt/buttongt
    echo ltdiv id=dw-ajax-status style=display:inline-blockmargin-left:10pxgtlt/divgt
}

function dw_ajax_admin_assets(hook) {
    // The dashboard pages hook is index.php - only load on dashboard
    if (hook !== index.php) {
        return
    }

    wp_enqueue_script(dw-ajax-admin, plugin_dir_url(__FILE__) . dw-ajax-admin.js, array(jquery), 1.0, true)
    wp_localize_script(dw-ajax-admin, dwAjax, array(
        ajax_url => admin_url(admin-ajax.php),
        nonce    => wp_create_nonce(dw_ajax_nonce),
    ))
}

function dw_ajax_save_handler() {
    check_ajax_referer(dw_ajax_nonce, nonce)

    if (!current_user_can(manage_options)) {
        wp_send_json_error(__(Unauthorized, dw-ajax))
    }

    content = isset(_POST[content]) ? wp_kses_post(_POST[content]) : 
    update_option(dw_ajax_content, content)

    wp_send_json_success(__(Saved, dw-ajax))
}
// File: dw-ajax-admin.js
jQuery(function(){
    (#dw-ajax-save).on(click, function(e){
        e.preventDefault()
        var content = (#dw-ajax-content).val()
        (#dw-ajax-status).text(Saving...)
        .post(dwAjax.ajax_url, {
            action: dw_save_ajax,
            nonce: dwAjax.nonce,
            content: content
        }, function(response){
            if (response  response.success) {
                (#dw-ajax-status).text(response.data  Saved)
            } else {
                var msg = response  response.data ? response.data : Error
                (#dw-ajax-status).text(msg)
            }
        })
    })
})

Security and UX considerations for AJAX

  • Always use check_ajax_referer with a nonce.
  • Use capability checks server-side.
  • Provide user feedback in the UI for success or failure.
  • Only enqueue the JS on the dashboard to avoid extra load in other admin pages.

Advanced: Add meta box variant (control context and priority)

If you need more control over context and priority (which column/section the box appears in), use add_meta_box with the screen dashboard. This gives context values and priority control similar to other admin pages.

add_action(wp_dashboard_setup, dw_meta_box_widget)

function dw_meta_box_widget() {
    // screen: dashboard, context: side or normal, priority: highdefaultlow
    add_meta_box(
        dw_meta_widget,
        __(Meta Box Widget, dw-domain),
        dw_meta_widget_display,
        dashboard, // screen
        side,      // context: side  normal  column3 (depends on WP version)
        high       // priority
    )
}

function dw_meta_widget_display() {
    echo ltpgt . esc_html__(This is a meta_box-style dashboard widget., dw-domain) . lt/pgt
}

Removing or reordering default dashboard widgets

WordPress ships with several default dashboard widgets. Use remove_meta_box to remove them (hook into wp_dashboard_setup). Some common widget IDs include dashboard_quick_press, dashboard_primary, dashboard_right_now, and dashboard_activity (IDs can vary by WP version/plugins).

add_action(wp_dashboard_setup, dw_remove_default_dashboard_widgets)

function dw_remove_default_dashboard_widgets() {
    remove_meta_box(dashboard_quick_press, dashboard, side) // Quick Draft / Quick Press
    remove_meta_box(dashboard_primary, dashboard, side) // WordPress events/news
    // Remove other meta boxes as needed...
}

Security checklist (must-haves)

  1. Capability checks: use current_user_can to verify a user has the right capability before showing or handling sensitive actions.
  2. Nonces: use wp_nonce_field in forms and verify with check_admin_referer. For AJAX use check_ajax_referer.
  3. Sanitize input: sanitize_text_field for plain text wp_kses_post for HTML from trusted users.
  4. Escape output: esc_html, esc_textarea, esc_attr, or wp_kses_post depending on the context.
  5. Redirect safely: use wp_safe_redirect when sending users elsewhere after POSTs.

Localization (internationalization)

To make strings translatable, wrap them in __(), _e(), esc_html__(), etc., and set a text domain. Optionally call load_plugin_textdomain() on plugins_loaded or use modern standards like plugin_textdomain and translation files placed in the plugin’s languages folder.

Performance UX tips

  • Load admin JS/CSS only on the dashboard page (check the hook parameter in admin_enqueue_scripts – dashboard is index.php).
  • Cache heavy widget data if you call external APIs use transients to reduce API calls.
  • Keep widget markup minimal to avoid layout thrashing in the admin screen.
  • Use non-blocking saves (AJAX) for better UX. Show inline validation and status messages.

Troubleshooting common issues

  • Widget not appearing: Ensure the plugin is activated and your code hooks into wp_dashboard_setup. Confirm no fatal errors in PHP logs. Check that the current user has the capability required to view the widget.
  • Form POST results in blank page or not saved: Use admin-post.php and add an admin_post_ action. Confirm nonce name and check parameter names match between the form and handler.
  • AJAX returns 0 or Error: In WP AJAX, a bare 0 usually means a PHP fatal or permission/nonce failure. Check PHP error logs and confirm check_ajax_referer and capability checks.
  • Words in the widget are not translated: Ensure you used translation functions and set the correct text domain. Load textdomain in plugin init if needed.

Useful function references

  • wp_add_dashboard_widget(id, title, callback) — register a simple dashboard widget.
  • add_meta_box(id, title, callback, screen, context, priority) — register a meta box with context/priority control use screen = dashboard for dashboard meta boxes.
  • remove_meta_box(id, screen, context) — remove a dashboard meta box.
  • admin_post_{action} — hook for handling non-AJAX form submissions to admin-post.php.
  • wp_ajax_{action} — hook for handling logged-in AJAX requests.

Example: Full small plugin combining many elements

The following example is a concise plugin that registers a widget with a form posting to admin-post, with nonce, capability checks, saving to an option, and a redirect back to the dashboard. Combine and adapt pieces from earlier examples to suit more advanced needs.

lt?php
/
  Plugin Name: Dashboard Notes
  Description: Custom dashboard widget for admin notes. Demonstrates form handling via admin-post and security best practices.
  Version: 1.0
  Author: Your Name
  Text Domain: dw-notes
 /

if ( ! defined( ABSPATH ) ) {
    exit
}

add_action(wp_dashboard_setup, dw_notes_register)
add_action(admin_post_dw_notes_save, dw_notes_save_handler)

function dw_notes_register() {
    wp_add_dashboard_widget(
        dw_notes_widget,
        __(Admin Notes, dw-notes),
        dw_notes_display
    )
}

function dw_notes_display() {
    if (!current_user_can(manage_options)) {
        echo ltpgt . esc_html__(Insufficient permissions., dw-notes) . lt/pgt
        return
    }

    notes = get_option(dw_notes_content, )

    echo ltform action= . esc_url(admin_url(admin-post.php)) .  method=postgt
    echo ltinput type=hidden name=action value=dw_notes_save /gt
    wp_nonce_field(dw_notes_save_action, dw_notes_nonce)

    echo ltpgtlttextarea name=dw_notes_content rows=6 cols=40gt . esc_textarea(notes) . lt/textareagtlt/pgt
    echo ltpgtltinput type=submit class=button button-primary value= . esc_attr__(Save Notes, dw-notes) .  /gtlt/pgt
    echo lt/formgt
}

function dw_notes_save_handler() {
    if (!current_user_can(manage_options)) {
        wp_die(__(Unauthorized, dw-notes))
    }

    check_admin_referer(dw_notes_save_action, dw_notes_nonce)

    notes = isset(_POST[dw_notes_content]) ? wp_kses_post(trim(_POST[dw_notes_content])) : 
    update_option(dw_notes_content, notes)

    // Use wp_get_referer to go back to the dashboard
    redirect = wp_get_referer() ? wp_get_referer() : admin_url()
    wp_safe_redirect(redirect)
    exit
}

Checklist before shipping a dashboard widget

  • Have you added capability checks so only intended users see/submit data?
  • Are all inputs sanitized and all outputs escaped?
  • Are nonces in place for forms and AJAX?
  • Is the widget localized with proper text domain usage?
  • Are admin assets enqueued only on relevant pages?
  • Did you consider caching or transient use for heavy external data?

Final notes and recommendations

Dashboard widgets are a lightweight way to add admin-specific functionality. For anything user-editable, follow the security checklist strictly. Prefer AJAX for small, frequent updates to improve the user experience. Package your widget as a plugin for portability and maintenance. Test with different administrator-level accounts and WP installs to ensure compatibility.

Further exploration

  • Use the WordPress REST API to save or retrieve dashboard data from external services.
  • Combine widgets with custom post types for richer admin UIs.
  • Explore third-party libraries for richer editors (e.g., TinyMCE) but ensure proper sanitization when saving.
  • Consider placing widgets behind feature flags or options to allow site owners to enable/disable them easily.


Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

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