Xây plugin quản lý Dashboard Widgets WordPress

Mục lục bài viết18 mục

Mình sẽ hướng dẫn bạn xây một plugin gọn và dễ bảo trì để thêm, sửa, xóa và sắp xếp Dashboard Widgets, ẩn hiện widget mặc định, bảo mật bằng capability và nonce, lưu cấu hình qua Options API, tôn trọng Screen Options. Ví dụ minh họa dùng Dashboard Widgets trên WordPress single-site hiện đại.

Xác lập phạm vi, giả định và mục tiêu

Trong toàn bộ hướng dẫn này, “phần hiển thị trên Dashboard” được hiểu chính xác là các Dashboard Widgets trên màn hình quản trị WordPress tại wp-admin/index.php. Đây là các meta box thuộc screen id dashboard, có context normal hoặc side, do core và plugin đăng ký. Những thành phần không phải meta box như Welcome panel hay admin_notices sẽ được nhắc riêng khi cần để tránh nhầm lẫn.

Giả định môi trường mặc định là single-site trên WordPress hiện đại. Bạn đã có kiến thức cơ bản PHP và WordPress: hiểu action, filter, cấu trúc plugin, cách làm việc với Options API, và quen tay với thao tác admin. Hướng dẫn không bao gồm cài đặt WordPress, xây theme hay hướng dẫn PHP nhập môn. Khi có khác biệt cho multisite, mình sẽ kèm lưu ý ngắn – ví dụ Network Admin dùng hook wpnetworkdashboardsetup và capability cấp mạng như managenetwork, còn User Admin dùng wpuserdashboardsetup. Bạn có thể kiểm tra ngữ cảnh nhanh qua isnetworkadmin hoặc isuser_admin trên máy mình.

Mục tiêu cuối cùng là tạo một plugin độc lập, có kiến trúc rõ ràng và dễ bảo trì, cho phép:

  • Thêm widget tùy chỉnh với tiêu đề, nội dung, capability, vị trí cột và ưu tiên hiển thị.
  • Sửa nội dung và thuộc tính hiển thị của widget an toàn, có kiểm tra xung đột khi lưu.
  • Xóa widget gọn gàng, có dọn dẹp dữ liệu liên quan.
  • Ẩn hoặc hiện các widget mặc định của WordPress theo cấu hình, không làm vỡ bố cục và tôn trọng lựa chọn người dùng khi phù hợp.

Các nguyên tắc bắt buộc để đạt độ an toàn và ổn định:

  • Đăng ký và gỡ widget đúng luồng wpdashboardsetup, dùng wpadddashboardwidget và removemeta_box đúng context – priority.
  • Kiểm soát phân quyền bằng currentusercan theo mục đích widget, không gọi callback tùy ý từ dữ liệu lưu trữ.
  • Chống CSRF bằng nonce, sanitize đầu vào, escape đầu ra.
  • Lưu cấu hình tập trung bằng Options API có versioning để cập nhật an toàn và dễ migrate.
  • Tôn trọng Screen Options và thứ tự kéo thả của người dùng, chỉ ép chính sách khi được cấu hình rõ.

Cách Dashboard widget hoạt động trong WordPress

Khi trang wp-admin/index.php được tải, WordPress khởi tạo WPScreen cho screen id dashboard, gọi hook wpdashboardsetup, rồi render meta box theo thứ tự và trạng thái ẩn hiện của từng người dùng. Ở bước wpdashboardsetup, core và plugin đăng ký các Dashboard widget bằng API wpadddashboardwidget. Mỗi widget thực chất là một meta box có id duy nhất, tiêu đề, và callback in nội dung. Sau khi đăng ký xong, WordPress đọc user meta để biết widget nào bị ẩn, thứ tự sắp xếp ra sao, rồi gọi dometaboxes để hiển thị.

Bạn cần nắm 3 mảnh ghép:

  • Đăng ký: dùng wpadddashboardwidget trong hook wpdashboard_setup. API này thêm widget vào context normal với priority core.
  • Lưu trữ tạm thời: mọi meta box đang có mặt được đặt trong mảng toàn cục $wpmetaboxes theo 4 tầng [screen][context][priority][id]. Riêng Dashboard dùng 2 context là normal và side. Priority phổ biến là high, core, default, low. Khi người dùng đã sắp xếp, core sẽ đổ chúng vào bucket sorted theo user meta.
  • Quy tắc người dùng: tab Screen Options ở góc phải thao tác trên 2 khóa user meta
  • metaboxhidden_dashboard: mảng ID widget đang bị ẩn.
  • meta-box-orderdashboard: thứ tự widget theo từng context. Khi tồn tại, thứ tự này được ưu tiên hơn priority trong $wpmeta_boxes.

Ví dụ đầy đủ: đăng ký một widget và đặt mặc định sang cột side với priority low. Mình chọn priority 99 để di chuyển sau khi đa số widget đã được thêm vào.

<?php
// file plugin hoặc mu-plugin
add_action('wp_dashboard_setup', function () {
    $widget_id = 'mydash_notes';

    // 1) Đăng ký widget - mặc định rơi vào normal-core
    wp_add_dashboard_widget(
        $widget_id,
        'Team notes',
        function () {
            echo '<p>Ghi chú nhanh cho team. Bạn có thể sửa nội dung trong phần cấu hình.</p>';
        }
    );

    // 2) Di chuyển mặc định sang cột side, priority low
    global $wp_meta_boxes;

    $page         = 'dashboard';
    $src_context  = 'normal';
    $dst_context  = 'side';
    $dst_priority = 'low';

    $possible_src = array('core', 'default', 'high', 'low');
    $box = null;

    foreach ($possible_src as $p) {
        if (isset($wp_meta_boxes[$page][$src_context][$p][$widget_id])) {
            $box = $wp_meta_boxes[$page][$src_context][$p][$widget_id];
            unset($wp_meta_boxes[$page][$src_context][$p][$widget_id]);
            break;
        }
    }

    if ($box) {
        // Bảo đảm mảng đích tồn tại
        if (!isset($wp_meta_boxes[$page][$dst_context])) {
            $wp_meta_boxes[$page][$dst_context] = array();
        }
        if (!isset($wp_meta_boxes[$page][$dst_context][$dst_priority])) {
            $wp_meta_boxes[$page][$dst_context][$dst_priority] = array();
        }

        // Cập nhật metadata trong box
        $box['context']  = $dst_context;
        $box['priority'] = $dst_priority;

        // Đặt vào side-low
        $wp_meta_boxes[$page][$dst_context][$dst_priority][$widget_id] = $box;
    }
}, 99);

Biến thể linh hoạt cùng ý tưởng, bạn chỉ cần đổi biến mục tiêu:

<?php
add_action('wp_dashboard_setup', function () {
    $widget_id   = 'sales_summary';
    $dst_context = 'side';   // 'normal' hoặc 'side'
    $dst_priority = 'low';   // 'high' | 'core' | 'default' | 'low'

    wp_add_dashboard_widget($widget_id, 'Sales summary', function () {
        echo '<p>Doanh số 7 ngày gần nhất.</p>';
    });

    global $wp_meta_boxes;
    $page = 'dashboard';
    $src_context = 'normal';
    foreach (array('core','default','high','low') as $p) {
        if (isset($wp_meta_boxes[$page][$src_context][$p][$widget_id])) {
            $box = $wp_meta_boxes[$page][$src_context][$p][$widget_id];
            unset($wp_meta_boxes[$page][$src_context][$p][$widget_id]);
            $box['context']  = $dst_context;
            $box['priority'] = $dst_priority;
            $wp_meta_boxes[$page][$dst_context][$dst_priority][$widget_id] = $box;
            break;
        }
    }
}, 99);

Điều quan trọng khi bạn di chuyển box trong $wpmetaboxes là tôn trọng lựa chọn người dùng. Nếu user đã từng kéo thả và core đã lưu meta-box-order_dashboard, core sẽ ưu tiên thứ tự trong user meta và bỏ qua vị trí mặc định bạn vừa đặt. Đây là hành vi đúng để không làm thay đổi dashboard cá nhân.

Ẩn hiện liên quan Screen Options hoạt động như sau: lần đầu người dùng vào Dashboard, WordPress dùng danh sách mặc định để xác định widget nào ẩn. Khi người dùng tick hoặc bỏ tick trong Screen Options, giá trị được lưu vào metaboxhiddendashboard và ngay từ lần tải sau, danh sách ẩn hiện sẽ tuân theo user meta này. Bạn có thể gỡ hẳn một widget nào đó khỏi giao diện bằng removemetabox trong cùng hook wpdashboard_setup khi cần, khi đó nó cũng biến mất khỏi Screen Options.

Cuối cùng, lưu ý rằng có thành phần trông giống widget nhưng không phải meta box. Điển hình là Welcome panel. Nó xuất hiện qua hook welcomepanel chứ không nằm trong $wpmetaboxes, nên removemetabox không tác dụng. Nếu muốn tắt, bạn cần gỡ hành vi in panel bằng removeaction trên hook đó:

<?php
add_action('wp_dashboard_setup', function () {
    remove_action('welcome_panel', 'wp_welcome_panel');
});

Nắm đúng luồng này giúp bạn thêm, sắp xếp hoặc ẩn widget một cách có kiểm soát, không phá vỡ lựa chọn cá nhân của người dùng và không nhầm lẫn giữa meta box với các khối đặc thù như Welcome panel.

Thiết kế kiến trúc plugin tách biệt, dễ bảo trì

Bạn nên đóng gói mọi thứ thành một plugin độc lập, có bootstrap rõ ràng, autoloader đơn giản, hook activation – deactivation – uninstall chuẩn, module hóa lớp lưu trữ, trang cấu hình, registry widget, rendering và security. Cách này giúp bật tắt plugin mà không đụng theme hay plugin khác, đồng thời dễ phát triển dài hạn.

Các bước thực hiện:

  1. Tạo cấu trúc thư mục gọn gàng
  • mydash/
  • mydash.php
  • uninstall.php
  • inc/
  • Autoload.php
  • Config.php
  • Storage.php
  • Registry.php
  • Rendering.php
  • Security.php
  • admin/
  • AdminPage.php
  • languages/
  • mydash.pot
  1. File plugin chính: bootstrap, autoloader, hook vòng đời, text domain, Settings link và khởi động module

Ví dụ hoàn chỉnh:

<?php
/**
 * Plugin Name: MyDash - Custom Admin Dashboard
 * Description: Quản lý Dashboard widgets tùy chỉnh: thêm, sửa, xóa và ẩn/hiện widget mặc định.
 * Version: 1.0.0
 * Author: Your Name
 * Text Domain: mydash
 * Domain Path: /languages
 */

defined('ABSPATH') || exit;

define('MYDASH_FILE', __FILE__);
define('MYDASH_DIR', plugin_dir_path(__FILE__));
define('MYDASH_URL', plugin_dir_url(__FILE__));
define('MYDASH_VER', '1.0.0');
define('MYDASH_CAP_MANAGE', 'manage_custom_dashboard');

// 1) Autoloader PSR-4 đơn giản cho namespace MyDash\
require_once MYDASH_DIR . 'inc/Autoload.php';
MyDash\Autoload::register('MyDash\\', MYDASH_DIR . 'inc/');

// 2) Nạp i18n
add_action('init', function () {
    load_plugin_textdomain('mydash', false, dirname(plugin_basename(__FILE__)) . '/languages');
});

// 3) Hook activation: cấp capability riêng, khởi tạo option cấu hình
register_activation_hook(__FILE__, function ($network_wide) {
    // Cấp quyền cho Administrator
    $grant_cap = function () {
        $role = get_role('administrator');
        if ($role && ! $role->has_cap(MYDASH_CAP_MANAGE)) {
            $role->add_cap(MYDASH_CAP_MANAGE);
        }
        // Khởi tạo option cấu hình mặc định nếu chưa có
        \MyDash\Storage::ensure_defaults();
    };

    if (is_multisite() && $network_wide) {
        $sites = get_sites(array('fields' => 'ids'));
        foreach ($sites as $blog_id) {
            switch_to_blog($blog_id);
            $grant_cap();
            restore_current_blog();
        }
    } else {
        $grant_cap();
    }
});

// 4) Hook deactivation: giữ dữ liệu, không làm gì để tránh mất cấu hình
register_deactivation_hook(__FILE__, function () {
    // Cố ý bỏ trống: giữ dữ liệu và capability để tránh gián đoạn khi kích hoạt lại
});

// 5) Link Settings ngay tại danh sách Plugins
add_filter('plugin_action_links_' . plugin_basename(__FILE__), function ($links) {
    $url = admin_url('options-general.php?page=mydash');
    $links[] = '<a href="' . esc_url($url) . '">' . esc_html__('Settings', 'mydash') . '</a>';
    return $links;
});

// 6) Khởi động các module khi plugin sẵn sàng
add_action('plugins_loaded', function () {
    // Trang cấu hình
    (new \MyDash\Admin\AdminPage())->init();

    // Registry widget và hook đăng ký widget vào Dashboard
    $registry = new \MyDash\Registry();
    add_action('wp_dashboard_setup', [$registry, 'register_widgets'], 10);

    // Bạn có thể thêm hook chuyên biệt cho Network/User Dashboard ở các phần tiếp theo
});
  1. Autoloader: tránh require thủ công và giảm rối rắm
<?php
// inc/Autoload.php
namespace MyDash;

defined('ABSPATH') || exit;

final class Autoload
{
    public static function register(string $prefix, string $base_dir): void
    {
        spl_autoload_register(function ($class) use ($prefix, $base_dir) {
            $len = strlen($prefix);
            if (strncmp($prefix, $class, $len) !== 0) {
                return;
            }
            $relative = substr($class, $len);
            $file = $base_dir . str_replace('\\', '/', $relative) . '.php';
            if (is_readable($file)) {
                require $file;
            }
        });
    }
}
  1. Module Config và Storage: một chỗ quyết định mặc định, một lớp bọc Options API
<?php
// inc/Config.php
namespace MyDash;

defined('ABSPATH') || exit;

final class Config
{
    // Tên option lưu toàn bộ cấu hình plugin
    public const OPTION_NAME = 'mydash_dashboard_widgets';

    // Cấu hình mặc định, autoload=no sẽ thiết lập ở lần add_option đầu tiên
    public static function defaults(): array
    {
        return [
            'schema_version' => 1,
            'rev' => 1,
            'widgets' => [
                // Ví dụ một widget mặc định
                'team_notes' => [
                    'id' => 'team_notes',
                    'title' => __('Team notes', 'mydash'),
                    'enabled' => true,
                    'type' => 'notes',
                    'context' => 'side',
                    'priority' => 'low',
                    'capability' => 'edit_posts',
                    'data' => [
                        'content' => __('Welcome to your custom dashboard.', 'mydash'),
                    ],
                    'updated_at' => gmdate('c'),
                ],
            ],
            'hidden_core' => [
                // ví dụ: 'dashboard_primary' => true
            ],
            'policy' => [
                'forced_hide' => [],
                'forced_show' => [],
            ],
        ];
    }
}
<?php
// inc/Storage.php
namespace MyDash;

defined('ABSPATH') || exit;

final class Storage
{
    public static function ensure_defaults(): void
    {
        $name = Config::OPTION_NAME;
        if (get_option($name, null) === null) {
            add_option($name, Config::defaults(), '', 'no'); // autoload=no
        }
    }

    public static function get(): array
    {
        $cfg = get_option(Config::OPTION_NAME, []);
        return is_array($cfg) ? $cfg : [];
    }

    public static function update(array $new): bool
    {
        return update_option(Config::OPTION_NAME, $new, false);
    }
}
  1. Security: gom hàm kiểm tra capability và nonce để dùng nhất quán
<?php
// inc/Security.php
namespace MyDash;

defined('ABSPATH') || exit;

final class Security
{
    public static function can_manage(): bool
    {
        return current_user_can(MYDASH_CAP_MANAGE);
    }

    public static function enforce_manage(): void
    {
        if (! self::can_manage()) {
            wp_die(esc_html__('You are not allowed to manage this dashboard.', 'mydash'), 403);
        }
    }

    public static function nonce_field(string $action, string $name = '_mydash_nonce'): void
    {
        wp_nonce_field($action, $name);
    }

    public static function verify_nonce(string $action, string $name = '_mydash_nonce'): void
    {
        check_admin_referer($action, $name);
    }
}
  1. Registry: nơi duy nhất khai báo các type và callback rendering, không đọc tên hàm từ DB
<?php
// inc/Registry.php
namespace MyDash;

defined('ABSPATH') || exit;

final class Registry
{
    private array $types;

    public function __construct()
    {
        $this->types = [
            // Mỗi type ánh xạ tới callable an toàn do plugin kiểm soát
            'notes' => [Rendering::class, 'render_notes'],
            // Bạn có thể bổ sung type mới tại đây
        ];
    }

    public function register_widgets(): void
    {
        if (! is_admin()) {
            return;
        }

        $cfg = Storage::get();
        if (empty($cfg['widgets']) || !is_array($cfg['widgets'])) {
            return;
        }

        foreach ($cfg['widgets'] as $id => $w) {
            if (empty($w['enabled'])) {
                continue;
            }
            // Kiểm tra capability người dùng hiện tại cho widget
            $cap = !empty($w['capability']) ? $w['capability'] : 'read';
            if (! current_user_can($cap)) {
                continue;
            }

            $type = $w['type'] ?? '';
            $cb   = $this->types[$type] ?? null;
            if (! is_callable($cb)) {
                continue;
            }

            // Đăng ký widget - vào normal/core mặc định
            wp_add_dashboard_widget(
                $id,
                $w['title'] ?? $id,
                function () use ($cb, $w) {
                    call_user_func($cb, $w);
                }
            );

            // Di chuyển sang context/priority mong muốn
            $context  = $w['context']  ?? 'normal';
            $priority = $w['priority'] ?? 'core';
            $this->move_box('dashboard', $id, $context, $priority);
        }
    }

    private function move_box(string $screen, string $widget_id, string $dst_context, string $dst_priority): void
    {
        global $wp_meta_boxes;
        $src_context = 'normal';
        foreach (['core','default','high','low'] as $p) {
            if (isset($wp_meta_boxes[$screen][$src_context][$p][$widget_id])) {
                $box = $wp_meta_boxes[$screen][$src_context][$p][$widget_id];
                unset($wp_meta_boxes[$screen][$src_context][$p][$widget_id]);

                if (!isset($wp_meta_boxes[$screen][$dst_context][$dst_priority])) {
                    $wp_meta_boxes[$screen][$dst_context][$dst_priority] = [];
                }
                $box['context']  = $dst_context;
                $box['priority'] = $dst_priority;
                $wp_meta_boxes[$screen][$dst_context][$dst_priority][$widget_id] = $box;
                break;
            }
        }
    }
}
  1. Rendering: nơi in nội dung widget, giữ an toàn mặc định
<?php
// inc/Rendering.php
namespace MyDash;

defined('ABSPATH') || exit;

final class Rendering
{
    public static function render_notes(array $widget): void
    {
        $content = $widget['data']['content'] ?? '';
        echo '<div class="mydash-notes">';
        echo wp_kses_post($content);
        echo '</div>';
    }
}
  1. Trang cấu hình admin: một menu dưới Settings và trang giao diện riêng, có kiểm tra capability
<?php
// admin/AdminPage.php
namespace MyDash\Admin;

use MyDash\Security;
use MyDash\Storage;

defined('ABSPATH') || exit;

final class AdminPage
{
    public function init(): void
    {
        add_action('admin_menu', [$this, 'register_menu']);
        add_action('admin_post_mydash_save_settings', [$this, 'handle_save']);
    }

    public function register_menu(): void
    {
        add_options_page(
            __('MyDash Dashboard', 'mydash'),
            __('MyDash Dashboard', 'mydash'),
            MYDASH_CAP_MANAGE,
            'mydash',
            [$this, 'render']
        );
    }

    public function render(): void
    {
        Security::enforce_manage();
        $cfg = Storage::get();
        ?>
        <div class="wrap">
            <h1><?php echo esc_html__('Custom Dashboard', 'mydash'); ?></h1>
            <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
                <input type="hidden" name="action" value="mydash_save_settings" />
                <?php \MyDash\Security::nonce_field('mydash_save_settings'); ?>
                <p><?php echo esc_html__('This is a minimal settings page. The full CRUD UI will be added in later steps.', 'mydash'); ?></p>
                <p>
                    <label for="team_notes_content"><?php echo esc_html__('Team notes content', 'mydash'); ?></label>
                    <textarea id="team_notes_content" name="team_notes_content" class="large-text" rows="5"><?php
                        $content = $cfg['widgets']['team_notes']['data']['content'] ?? '';
                        echo esc_textarea($content);
                    ?></textarea>
                </p>
                <p><button class="button button-primary"><?php echo esc_html__('Save changes', 'mydash'); ?></button></p>
            </form>
        </div>
        <?php
    }

    public function handle_save(): void
    {
        \MyDash\Security::enforce_manage();
        \MyDash\Security::verify_nonce('mydash_save_settings');

        $cfg = Storage::get();
        $new = $cfg;

        $content = isset($_POST['team_notes_content']) ? wp_kses_post(wp_unslash($_POST['team_notes_content'])) : '';
        if (isset($new['widgets']['team_notes'])) {
            $new['widgets']['team_notes']['data']['content'] = $content;
            $new['widgets']['team_notes']['updated_at'] = gmdate('c');
            $new['rev'] = intval($new['rev'] ?? 1) + 1;
        }

        Storage::update($new);

        wp_safe_redirect(add_query_arg('updated', 1, admin_url('options-general.php?page=mydash')));
        exit;
    }
}
  1. Uninstall: dọn sạch option và thu hồi capability khỏi Administrator ở mọi site
<?php
// uninstall.php
defined('WP_UNINSTALL_PLUGIN') || exit;

require_once __DIR__ . '/inc/Config.php';

$delete_everything = function () {
    // Xóa option cấu hình chính
    delete_option(\MyDash\Config::OPTION_NAME);

    // Thu hồi capability tùy chỉnh
    $role = get_role('administrator');
    if ($role && $role->has_cap('manage_custom_dashboard')) {
        $role->remove_cap('manage_custom_dashboard');
    }
};

if (is_multisite()) {
    $sites = get_sites(array('fields' => 'ids'));
    foreach ($sites as $blog_id) {
        switch_to_blog($blog_id);
        $delete_everything();
        restore_current_blog();
    }
} else {
    $delete_everything();
}

Ghi chú áp dụng thực tế

  • Tên hàm, hằng và option đều có tiền tố mydash để tránh va chạm. Bạn có thể đổi slug theo dự án, miễn nhất quán.
  • Capability riêng managecustomdashboard chỉ cấp cho Administrator khi kích hoạt để kiểm soát trang cấu hình và các thao tác quản trị. Với multisite kích hoạt ở mức mạng, activation hook đã lặp mọi site để thêm cap đúng chỗ.
  • Text domain mydash đã được load qua init, thư mục languages để bạn xuất file .pot và dịch. Điều này phục vụ người đọc thứ cấp là quản trị viên nội bộ hay khách hàng cần giao diện Việt hóa hoặc đa ngôn ngữ.
  • Hook đăng ký widget đặt trong Registry qua wpdashboardsetup. Kiến trúc này đảm bảo khi vô hiệu hóa plugin, toàn bộ callback ngừng hoạt động ngay, không đụng chạm theme hoặc plugin khác.
  • Phần CRUD UI, chính sách ẩn hiện, kiểm soát priority và context sẽ nối tiếp trên nền các module này. Bạn đã có sẵn Security để dùng nonce và capability thống nhất, Storage để ghi đọc cấu hình một chỗ, Registry để kiểm soát callback an toàn và Rendering để giữ chuẩn sanitize khi hiển thị.

Mô hình lưu cấu hình bằng Options API, cấu trúc dữ liệu và versioning

Mình khuyến nghị lưu toàn bộ cấu hình widget vào một option duy nhất với autoload=no. Cách này giảm số truy vấn, cập nhật gần như nguyên tử ở cấp hàng, dễ kiểm soát version và migrate mà không rơi vào trạng thái dở dang. Dưới đây là mô hình dữ liệu, quy ước khóa, cơ chế khóa lạc quan bằng rev, kịch bản migrate an toàn và trường hợp bất khả kháng phải tách nhỏ.

Mô hình dữ liệu và quy ước khóa

  • Tên option: tiền tố theo plugin, ví dụ mydashdashboardwidgets. Không nhúng version vào tên option.
  • Autoload: no ngay từ lần add_option đầu tiên.
  • Quy ước id và slug:
  • id dùng làm meta box ID và key trong mảng widgets. Chỉ dùng a-z, 0-9 và gạch dưới. Chuẩn hóa bằng sanitizekey, ví dụ salessummary, team_notes.
  • slug để hiển thị URL hoặc nhãn trong UI quản trị, cho phép gạch ngang. Ví dụ team-notes. Có thể trùng về mặt ngữ nghĩa với id nhưng khác ký tự phân tách.
  • Dấu thời gian: ISO 8601 UTC, dùng gmdate(‘c’). Lưu createdat và updatedat để dễ kiểm soát thay đổi.
  • Cấu trúc đề xuất:
  • schema_version: version của schema option.
  • rev: số nguyên tăng dần mỗi lần ghi, dùng cho khóa lạc quan.
  • widgets: mảng kết hợp key là id. Mỗi widget có id, slug, title, type, capability, enabled, context, priority, data, createdat, updatedat.
  • hidden_core: map id widget core cần ẩn khỏi Dashboard ở tầm hệ thống.
  • forced_hide: danh sách id widget phải ẩn cho mọi người dùng.
  • forced_show: danh sách id widget phải hiện cho mọi người dùng.

Ví dụ payload hoàn chỉnh

<?php
$cfg = [
    'schema_version' => 2,
    'rev' => 7,
    'widgets' => [
        'team_notes' => [
            'id'         => 'team_notes',
            'slug'       => 'team-notes',
            'title'      => 'Team notes',
            'type'       => 'notes',
            'capability' => 'edit_posts',
            'enabled'    => true,
            'context'    => 'side',
            'priority'   => 'low',
            'data'       => [
                'content' => 'Welcome to your custom dashboard. Keep it tidy.',
            ],
            'created_at' => '2026-07-15T08:00:00Z',
            'updated_at' => '2026-07-17T10:20:00Z',
        ],
        'sales_summary' => [
            'id'         => 'sales_summary',
            'slug'       => 'sales-summary',
            'title'      => 'Sales summary',
            'type'       => 'report',
            'capability' => 'manage_woocommerce',
            'enabled'    => false,
            'context'    => 'normal',
            'priority'   => 'core',
            'data'       => [
                'range' => '7d',
            ],
            'created_at' => '2026-07-16T09:00:00Z',
            'updated_at' => '2026-07-16T09:00:00Z',
        ],
    ],
    'hidden_core' => [
        'dashboard_primary'     => true,
        'dashboard_quick_press' => false,
    ],
    'forced_hide' => [
        'dashboard_right_now',
    ],
    'forced_show' => [
        'team_notes',
    ],
];

Biến thể có biến số

<?php
$id = 'team_notes';
$cfg['widgets'][$id] = [
    'id'         => $id,
    'slug'       => str_replace('_', '-', $id),
    'title'      => 'Team notes',
    'type'       => 'notes',
    'capability' => 'edit_posts',
    'enabled'    => true,
    'context'    => 'side',
    'priority'   => 'low',
    'data'       => ['content' => '...'],
    'created_at' => gmdate('c'),
    'updated_at' => gmdate('c'),
];

Khởi tạo và bảo đảm autoload=no

<?php
// Gọi khi kích hoạt plugin hoặc lần đầu chạy
function mydash_ensure_defaults(): void {
    $name = 'mydash_dashboard_widgets';
    if (get_option($name, null) === null) {
        add_option($name, [
            'schema_version' => 2,
            'rev'            => 1,
            'widgets'        => [],
            'hidden_core'    => [],
            'forced_hide'    => [],
            'forced_show'    => [],
        ], '', 'no'); // autoload=no
    }
}

// Nếu option tồn tại nhưng từng để autoload=yes, đổi về no trong activation
function mydash_force_autoload_no(): void {
    $name = 'mydash_dashboard_widgets';
    $val  = get_option($name, null);
    if ($val !== null) {
        delete_option($name);
        add_option($name, $val, '', 'no');
    }
}

Cập nhật an toàn bằng khóa lạc quan rev

  • Ý tưởng: đọc rev hiện tại, người dùng chỉnh sửa trên form giữ rev đó. Khi submit, so sánh rev gửi lên và rev trong DB. Nếu khác, báo xung đột để tránh ghi đè thay đổi của người khác.
  • Mỗi lần lưu thành công, tăng rev +1 và cập nhật updated_at của bản ghi bị chỉnh.

Ví dụ handler tối giản

<?php
add_action('admin_post_mydash_save_cfg', function () {
    if (! current_user_can('manage_custom_dashboard')) {
        wp_die('Unauthorized', 403);
    }
    check_admin_referer('mydash_save_cfg', '_mydash_nonce');

    $stored = get_option('mydash_dashboard_widgets', []);
    $posted_rev = isset($_POST['rev']) ? (int) $_POST['rev'] : 0;
    $current_rev = isset($stored['rev']) ? (int) $stored['rev'] : 0;

    if ($posted_rev !== $current_rev) {
        wp_safe_redirect(add_query_arg('mydash_conflict', 1, admin_url('options-general.php?page=mydash')));
        exit;
    }

    // Ví dụ cập nhật nội dung team_notes
    $new = $stored;
    $content = isset($_POST['team_notes_content']) ? wp_kses_post(wp_unslash($_POST['team_notes_content'])) : '';
    if (isset($new['widgets']['team_notes'])) {
        $new['widgets']['team_notes']['data']['content'] = $content;
        $new['widgets']['team_notes']['updated_at'] = gmdate('c');
    }

    $new['rev'] = $current_rev + 1;
    update_option('mydash_dashboard_widgets', $new, false);

    wp_safe_redirect(add_query_arg('updated', 1, admin_url('options-general.php?page=mydash')));
    exit;
});

Tiêu chí tách thành nhiều option – và cách pointer swap

  • Cứ giữ một option duy nhất trừ khi:
  • Bạn có hàng trăm đến hàng nghìn widget cấu hình và chỉ cần nạp một phần nhỏ theo vai trò hay ngữ cảnh đặc thù.
  • Bạn có tiến trình nền ghi thường xuyên từng nhóm nhỏ và cần giảm va chạm ghi.
  • Nếu buộc phải tách:
  • Tạo một option chỉ mục autoload=no, ví dụ mydashwidgetsindex, trỏ tới phiên bản đang hoạt động.
  • Ghi cấu hình hoàn chỉnh vào khóa mới có hậu tố version, rồi đổi con trỏ trong chỉ mục – kỹ thuật pointer swap. Người đọc chỉ nhìn theo chỉ mục nên luôn thấy snapshot nhất quán.

Ví dụ pointer swap

<?php
// Ghi phiên bản mới v18
$new_ver = 'v18';
add_option("mydash_widgets_{$new_ver}", $cfg_new, '', 'no'); // hoặc update nếu đã tồn tại

// Đổi con trỏ active trong index
$idx = get_option('mydash_widgets_index', ['active' => 'v1', 'versions' => []]);
$idx['active'] = $new_ver;
$idx['versions'][$new_ver] = time();
update_option('mydash_widgets_index', $idx, false);

// Đọc cấu hình khi đăng ký widget
$idx = get_option('mydash_widgets_index', []);
$active = isset($idx['active']) ? $idx['active'] : 'v1';
$cfg = get_option("mydash_widgets_{$active}", []);

Kế hoạch migrate schema an toàn

  • Nguyên tắc: idempotent, có backup, xác minh sau khi ghi, chỉ chạy khi cần.
  • Thời điểm: trong activation hoặc admin_init khi truy cập trang cấu hình, có kiểm tra quyền.

Ví dụ migrate từ schema v1 sang v2 – tách policy cũ thành forcedhide và forcedshow ở top-level

<?php
function mydash_maybe_migrate_schema(): void {
    $name = 'mydash_dashboard_widgets';
    $cfg  = get_option($name, null);
    if (!is_array($cfg)) {
        return;
    }
    $sv = isset($cfg['schema_version']) ? (int) $cfg['schema_version'] : 1;
    if ($sv >= 2) {
        return;
    }

    // Backup
    add_option('mydash_dashboard_widgets_backup_v1', [
        'at'   => gmdate('c'),
        'data' => $cfg,
    ], '', 'no');

    // Chuyển đổi
    $cfg['forced_hide'] = isset($cfg['policy']['forced_hide']) && is_array($cfg['policy']['forced_hide'])
        ? array_values(array_map('sanitize_key', $cfg['policy']['forced_hide'])) : [];
    $cfg['forced_show'] = isset($cfg['policy']['forced_show']) && is_array($cfg['policy']['forced_show'])
        ? array_values(array_map('sanitize_key', $cfg['policy']['forced_show'])) : [];
    unset($cfg['policy']);

    // Chuẩn hóa widget keys
    if (!empty($cfg['widgets']) && is_array($cfg['widgets'])) {
        $normalized = [];
        foreach ($cfg['widgets'] as $id => $w) {
            $id = sanitize_key($w['id'] ?? $id);
            $w['id'] = $id;
            $w['slug'] = isset($w['slug']) ? sanitize_title($w['slug']) : str_replace('_', '-', $id);
            $w['updated_at'] = gmdate('c');
            $normalized[$id] = $w;
        }
        $cfg['widgets'] = $normalized;
    }

    $cfg['schema_version'] = 2;
    $cfg['rev'] = isset($cfg['rev']) ? ((int) $cfg['rev'] + 1) : 1;

    update_option($name, $cfg, false);

    // Xác minh
    $check = get_option($name, []);
    if (!is_array($check) || !isset($check['schema_version']) || (int) $check['schema_version'] !== 2) {
        // Khôi phục nếu cần
        $bk = get_option('mydash_dashboard_widgets_backup_v1', []);
        if (!empty($bk['data'])) {
            update_option($name, $bk['data'], false);
        }
    }
}

Ghi nhớ thực thi

  • Đọc: getoption một lần. Nếu trống, tạo mặc định bằng addoption với autoload=no.
  • Ghi: luôn kiểm tra quyền, nonce và rev. Tăng rev sau khi hợp lệ.
  • Không đưa dữ liệu động lớn vào option này. Số liệu báo cáo nên nạp khi render và nếu cần thì cache bằng transient riêng autoload=no.

Khi tuân thủ mô hình một option duy nhất, bạn được cấu hình gọn, truy vấn ít, kiểm soát phiên bản rõ ràng và nâng cấp an toàn. Khi thật sự phải tách, dùng chỉ mục và pointer swap để vẫn giữ nguyên tắc nhất quán khi đọc.

Tạo trang quản trị cấu hình bằng Settings API

Mình sẽ dựng một trang cấu hình trong wp-admin dùng Settings API để lưu chính sách ẩn hiện widget mặc định và hiển thị danh sách widget tùy chỉnh kèm nút thêm, sửa, xóa. Trang chỉ cho người có capability phù hợp truy cập, mọi form đều có nonce phòng CSRF, và thông báo theo chuẩn adminnotices. Cấu hình lưu trong một option duy nhất mydashdashboard_widgets như mô hình đã thống nhất.

Các bước thực hiện

  1. Tạo menu dưới Settings và đăng ký Settings API.
  2. Thiết kế form Settings để bật tắt widget mặc định bằng checkbox.
  3. Hiển thị bảng widget tùy chỉnh với nút Thêm, Sửa, Xóa.
  4. Viết handler admin-post cho thao tác thêm, sửa, xóa và dùng nonce.
  5. Tạo thông báo admin_notices cho thành công và lỗi.

Mẫu plugin đầy đủ để bạn chạy ngay

File chính của plugin, ví dụ wp-content/plugins/mydash/mydash.php:

<?php
/**
 * Plugin Name: MyDash - Dashboard Manager
 * Description: Trang cấu hình dashboard widgets, CRUD và ẩn hiện widget mặc định.
 * Version: 1.0.0
 * Author: Your Name
 * Text Domain: mydash
 */

if (!defined('ABSPATH')) {
    exit;
}

function mydash_cap(): string {
    // Có thể thay bằng capability riêng sau khi map cho role quản trị
    return 'manage_options';
}

// Bảo đảm option mặc định tồn tại và autoload=no
function mydash_bootstrap_defaults(): void {
    $name = 'mydash_dashboard_widgets';
    if (get_option($name, null) === null) {
        add_option($name, [
            'schema_version' => 2,
            'rev'            => 1,
            'widgets'        => [],
            'hidden_core'    => [],   // ['dashboard_primary' => true, ...]
            'forced_hide'    => [],
            'forced_show'    => [],
        ], '', 'no');
    }
}
register_activation_hook(__FILE__, 'mydash_bootstrap_defaults');

// 1) Menu dưới Settings
add_action('admin_menu', function () {
    add_options_page(
        __('MyDash Dashboard', 'mydash'),
        __('MyDash Dashboard', 'mydash'),
        mydash_cap(),
        'mydash',
        'mydash_render_settings_page'
    );
});

// 2) Đăng ký Settings API và field
add_action('admin_init', function () {
    if (!current_user_can(mydash_cap())) {
        return;
    }

    // Đăng ký option với sanitize
    register_setting('mydash_settings', 'mydash_dashboard_widgets', [
        'type'              => 'array',
        'sanitize_callback' => 'mydash_sanitize_settings',
        'default'           => [
            'schema_version' => 2,
            'rev'            => 1,
            'widgets'        => [],
            'hidden_core'    => [],
            'forced_hide'    => [],
            'forced_show'    => [],
        ],
    ]);

    // Section: Widget mặc định
    add_settings_section(
        'mydash_section_core',
        __('Widget mặc định của WordPress', 'mydash'),
        function () {
            echo '<p>' . esc_html__('Chọn widget mặc định cần ẩn. Thay đổi có hiệu lực trên Dashboard ở lần tải tiếp theo.', 'mydash') . '</p>';
        },
        'mydash'
    );

    add_settings_field(
        'mydash_field_hidden_core',
        __('Ẩn các widget', 'mydash'),
        'mydash_field_hidden_core',
        'mydash',
        'mydash_section_core'
    );

    // Section: Danh sách widget tùy chỉnh
    add_settings_section(
        'mydash_section_custom',
        __('Widget tùy chỉnh', 'mydash'),
        function () {
            echo '<p>' . esc_html__('Quản lý danh sách widget do bạn định nghĩa. Có thể thêm, sửa, xóa và bật tắt.', 'mydash') . '</p>';
        },
        'mydash'
    );

    add_settings_field(
        'mydash_field_widgets_table',
        __('Danh sách và thao tác', 'mydash'),
        'mydash_field_widgets_table',
        'mydash',
        'mydash_section_custom'
    );
});

// 3) Sanitize Settings: chỉ xử lý phần hidden_core và khóa lạc quan rev
function mydash_sanitize_settings($input) {
    if (!current_user_can(mydash_cap())) {
        return get_option('mydash_dashboard_widgets', []);
    }

    $stored = get_option('mydash_dashboard_widgets', []);
    $new    = $stored;

    // Khóa lạc quan: so rev
    $posted_rev   = isset($input['rev']) ? (int) $input['rev'] : 0;
    $current_rev  = isset($stored['rev']) ? (int) $stored['rev'] : 0;

    if ($posted_rev !== $current_rev) {
        add_settings_error('mydash', 'mydash_conflict', __('Dữ liệu đã thay đổi ở nơi khác. Vui lòng tải lại trang và thử lại.', 'mydash'), 'error');
        return $stored;
    }

    // Nhận checkbox ẩn widget core
    $allowed_core = [
        'dashboard_activity',
        'dashboard_primary',
        'dashboard_quick_press',
        'dashboard_site_health',
        'dashboard_right_now',
        'dashboard_recent_drafts',
        'dashboard_recent_comments',
        'dashboard_incoming_links', // có thể không còn trong bản mới, giữ để tương thích cũ
    ];

    $new_hidden = [];
    if (isset($input['hidden_core']) && is_array($input['hidden_core'])) {
        foreach ($allowed_core as $id) {
            $new_hidden[$id] = !empty($input['hidden_core'][$id]) ? true : false;
        }
    } else {
        foreach ($allowed_core as $id) {
            $new_hidden[$id] = false;
        }
    }

    $new['hidden_core'] = $new_hidden;
    $new['rev']         = $current_rev + 1;

    // Không ghi đè widgets ở đây. CRUD xử lý qua admin-post riêng.
    return $new;
}

// 4) Field renderer: checkbox ẩn widget core
function mydash_field_hidden_core() {
    $cfg = get_option('mydash_dashboard_widgets', []);
    $hidden = isset($cfg['hidden_core']) && is_array($cfg['hidden_core']) ? $cfg['hidden_core'] : [];

    $items = [
        'dashboard_activity'       => __('Hoạt động', 'mydash'),
        'dashboard_primary'        => __('Tin tức WordPress', 'mydash'),
        'dashboard_quick_press'    => __('Quick Draft', 'mydash'),
        'dashboard_site_health'    => __('Tình trạng sức khỏe trang', 'mydash'),
        'dashboard_right_now'      => __('At a Glance', 'mydash'),
        'dashboard_recent_drafts'  => __('Bản nháp gần đây', 'mydash'),
        'dashboard_recent_comments'=> __('Bình luận gần đây', 'mydash'),
    ];

    echo '<fieldset>';
    foreach ($items as $id => $label) {
        $checked = !empty($hidden[$id]) ? 'checked' : '';
        echo '<label style="display:block;margin:4px 0">';
        echo '<input type="checkbox" name="mydash_dashboard_widgets[hidden_core][' . esc_attr($id) . ']" value="1" ' . $checked . ' />';
        echo ' ' . esc_html($label) . ' <code>' . esc_html($id) . '</code>';
        echo '</label>';
    }
    echo '</fieldset>';
}

// 5) Field renderer: bảng widget và form CRUD
function mydash_field_widgets_table() {
    if (!current_user_can(mydash_cap())) {
        echo '<p>' . esc_html__('Bạn không có quyền xem mục này.', 'mydash') . '</p>';
        return;
    }

    $cfg = get_option('mydash_dashboard_widgets', []);
    $widgets = isset($cfg['widgets']) && is_array($cfg['widgets']) ? $cfg['widgets'] : [];
    $editing_id = isset($_GET['edit']) ? sanitize_key($_GET['edit']) : '';

    echo '<table class="widefat striped">';
    echo '<thead><tr>';
    echo '<th>' . esc_html__('ID', 'mydash') . '</th>';
    echo '<th>' . esc_html__('Tiêu đề', 'mydash') . '</th>';
    echo '<th>' . esc_html__('Type', 'mydash') . '</th>';
    echo '<th>' . esc_html__('Cap', 'mydash') . '</th>';
    echo '<th>' . esc_html__('Vị trí', 'mydash') . '</th>';
    echo '<th>' . esc_html__('Ưu tiên', 'mydash') . '</th>';
    echo '<th>' . esc_html__('Bật', 'mydash') . '</th>';
    echo '<th>' . esc_html__('Hành động', 'mydash') . '</th>';
    echo '</tr></thead><tbody>';

    if (!$widgets) {
        echo '<tr><td colspan="8">' . esc_html__('Chưa có widget tùy chỉnh.', 'mydash') . '</td></tr>';
    } else {
        foreach ($widgets as $id => $w) {
            $enabled  = !empty($w['enabled']);
            $context  = $w['context'] ?? 'normal';
            $priority = $w['priority'] ?? 'core';
            $title    = $w['title'] ?? $id;
            $type     = $w['type'] ?? 'unknown';
            $cap      = $w['capability'] ?? 'read';

            $edit_url = add_query_arg(['page' => 'mydash', 'edit' => $id], admin_url('options-general.php'));
            $del_url  = wp_nonce_url(
                admin_url('admin-post.php?action=mydash_delete_widget&wid=' . $id . '&rev=' . (int) ($cfg['rev'] ?? 0)),
                'mydash_delete_widget_' . $id
            );

            echo '<tr>';
            echo '<td><code>' . esc_html($id) . '</code></td>';
            echo '<td>' . esc_html($title) . '</td>';
            echo '<td><code>' . esc_html($type) . '</code></td>';
            echo '<td><code>' . esc_html($cap) . '</code></td>';
            echo '<td>' . esc_html($context) . '</td>';
            echo '<td>' . esc_html($priority) . '</td>';
            echo '<td>' . ($enabled ? '<span class="dashicons dashicons-yes-alt" aria-hidden="true"></span>' : '—') . '</td>';
            echo '<td>';
            echo '<a class="button button-small" href="' . esc_url($edit_url) . '">' . esc_html__('Sửa', 'mydash') . '</a> ';
            echo '<a class="button button-small button-link-delete" href="' . esc_url($del_url) . '" onclick="return confirm(\'Xóa widget ' . esc_js($id) . '?\')">' . esc_html__('Xóa', 'mydash') . '</a>';
            echo '</td>';
            echo '</tr>';
        }
    }

    echo '</tbody></table>';

    // Form Thêm mới
    $rev = (int) ($cfg['rev'] ?? 0);
    echo '<h4 style="margin-top:16px">' . esc_html__('Thêm widget mới', 'mydash') . '</h4>';
    echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
    echo '<input type="hidden" name="action" value="mydash_add_widget" />';
    echo '<input type="hidden" name="rev" value="' . esc_attr($rev) . '" />';
    wp_nonce_field('mydash_add_widget', '_mydash_nonce');
    echo '<table class="form-table"><tbody>';
    echo '<tr><th><label for="mydash_id_new">' . esc_html__('ID', 'mydash') . '</label></th><td><input type="text" id="mydash_id_new" name="id" class="regular-text" placeholder="team_notes" required pattern="[a-z0-9_]+" /></td></tr>';
    echo '<tr><th><label for="mydash_title_new">' . esc_html__('Tiêu đề', 'mydash') . '</label></th><td><input type="text" id="mydash_title_new" name="title" class="regular-text" placeholder="Team notes" required /></td></tr>';
    echo '<tr><th><label for="mydash_type_new">' . esc_html__('Type', 'mydash') . '</label></th><td><select id="mydash_type_new" name="type"><option value="notes">notes</option><option value="report">report</option></select> <span class="description">' . esc_html__('Ánh xạ sang callback có sẵn trong plugin.', 'mydash') . '</span></td></tr>';
    echo '<tr><th><label for="mydash_cap_new">' . esc_html__('Capability', 'mydash') . '</label></th><td><input type="text" id="mydash_cap_new" name="capability" class="regular-text" value="edit_posts" /></td></tr>';
    echo '<tr><th><label for="mydash_ctx_new">' . esc_html__('Context', 'mydash') . '</label></th><td><select id="mydash_ctx_new" name="context"><option value="normal">normal</option><option value="side">side</option></select></td></tr>';
    echo '<tr><th><label for="mydash_pri_new">' . esc_html__('Priority', 'mydash') . '</label></th><td><select id="mydash_pri_new" name="priority"><option value="core">core</option><option value="high">high</option><option value="default">default</option><option value="low">low</option></select></td></tr>';
    echo '<tr><th><label for="mydash_enabled_new">' . esc_html__('Bật', 'mydash') . '</label></th><td><label><input type="checkbox" id="mydash_enabled_new" name="enabled" value="1" checked /> ' . esc_html__('Kích hoạt', 'mydash') . '</label></td></tr>';
    echo '<tr><th><label for="mydash_content_new">' . esc_html__('Nội dung HTML', 'mydash') . '</label></th><td><textarea id="mydash_content_new" name="content" class="large-text code" rows="5"><p>Welcome to your dashboard</p></textarea></td></tr>';
    echo '</tbody></table>';
    submit_button(__('Thêm widget', 'mydash'), 'primary', 'submit', false);
    echo '</form>';

    // Form Sửa nếu đang chọn
    if ($editing_id && isset($widgets[$editing_id])) {
        $w = $widgets[$editing_id];
        echo '<hr /><h4 style="margin-top:16px">' . sprintf(esc_html__('Sửa widget: %s', 'mydash'), esc_html($editing_id)) . '</h4>';
        echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
        echo '<input type="hidden" name="action" value="mydash_update_widget" />';
        echo '<input type="hidden" name="wid" value="' . esc_attr($editing_id) . '" />';
        echo '<input type="hidden" name="rev" value="' . esc_attr($rev) . '" />';
        wp_nonce_field('mydash_update_widget_' . $editing_id, '_mydash_nonce');
        echo '<table class="form-table"><tbody>';
        echo '<tr><th>' . esc_html__('ID', 'mydash') . '</th><td><code>' . esc_html($editing_id) . '</code></td></tr>';
        echo '<tr><th><label for="mydash_title_edit">' . esc_html__('Tiêu đề', 'mydash') . '</label></th><td><input type="text" id="mydash_title_edit" name="title" class="regular-text" value="' . esc_attr($w['title'] ?? $editing_id) . '" required /></td></tr>';
        echo '<tr><th><label for="mydash_type_edit">' . esc_html__('Type', 'mydash') . '</label></th><td><select id="mydash_type_edit" name="type">';
        $types = ['notes', 'report'];
        foreach ($types as $t) {
            $sel = (($w['type'] ?? '') === $t) ? 'selected' : '';
            echo '<option value="' . esc_attr($t) . '" ' . $sel . '>' . esc_html($t) . '</option>';
        }
        echo '</select></td></tr>';
        echo '<tr><th><label for="mydash_cap_edit">' . esc_html__('Capability', 'mydash') . '</label></th><td><input type="text" id="mydash_cap_edit" name="capability" class="regular-text" value="' . esc_attr($w['capability'] ?? 'edit_posts') . '" /></td></tr>';
        echo '<tr><th><label for="mydash_ctx_edit">' . esc_html__('Context', 'mydash') . '</label></th><td><select id="mydash_ctx_edit" name="context">';
        foreach (['normal', 'side'] as $ctx) {
            $sel = (($w['context'] ?? 'normal') === $ctx) ? 'selected' : '';
            echo '<option value="' . esc_attr($ctx) . '" ' . $sel . '>' . esc_html($ctx) . '</option>';
        }
        echo '</select></td></tr>';
        echo '<tr><th><label for="mydash_pri_edit">' . esc_html__('Priority', 'mydash') . '</label></th><td><select id="mydash_pri_edit" name="priority">';
        foreach (['core', 'high', 'default', 'low'] as $pri) {
            $sel = (($w['priority'] ?? 'core') === $pri) ? 'selected' : '';
            echo '<option value="' . esc_attr($pri) . '" ' . $sel . '>' . esc_html($pri) . '</option>';
        }
        echo '</select></td></tr>';
        $enabled_checked = !empty($w['enabled']) ? 'checked' : '';
        echo '<tr><th><label for="mydash_enabled_edit">' . esc_html__('Bật', 'mydash') . '</label></th><td><label><input type="checkbox" id="mydash_enabled_edit" name="enabled" value="1" ' . $enabled_checked . ' /> ' . esc_html__('Kích hoạt', 'mydash') . '</label></td></tr>';
        $content_val = isset($w['data']['content']) ? $w['data']['content'] : '';
        echo '<tr><th><label for="mydash_content_edit">' . esc_html__('Nội dung HTML', 'mydash') . '</label></th><td><textarea id="mydash_content_edit" name="content" class="large-text code" rows="5">' . esc_textarea($content_val) . '</textarea></td></tr>';
        echo '</tbody></table>';
        submit_button(__('Cập nhật widget', 'mydash'), 'primary', 'submit', false);
        echo ' <a class="button" href="' . esc_url(admin_url('options-general.php?page=mydash')) . '">' . esc_html__('Hủy', 'mydash') . '</a>';
        echo '</form>';
    }

    // Chèn hidden field rev để Settings API nhìn thấy
    $rev = (int) ($cfg['rev'] ?? 0);
    echo '<input type="hidden" name="mydash_dashboard_widgets[rev]" value="' . esc_attr($rev) . '" />';
}

// 6) Render trang Settings: gồm form Settings API và khu CRUD ở field
function mydash_render_settings_page() {
    if (!current_user_can(mydash_cap())) {
        wp_die(esc_html__('Bạn không có quyền truy cập trang này.', 'mydash'), 403);
    }
    $cfg = get_option('mydash_dashboard_widgets', []);
    echo '<div class="wrap">';
    echo '<h1>' . esc_html__('MyDash Dashboard', 'mydash') . '</h1>';

    // Thông báo chung của Settings API
    settings_errors('mydash');

    echo '<form method="post" action="options.php">';
    settings_fields('mydash_settings');    // nonce và option_page
    do_settings_sections('mydash');        // render 2 section và các field
    submit_button(__('Lưu thay đổi', 'mydash'));
    echo '</form>';

    echo '</div>';
}

// 7) Handlers admin-post cho Thêm, Cập nhật, Xóa
add_action('admin_post_mydash_add_widget', function () {
    if (!current_user_can(mydash_cap())) {
        wp_die(esc_html__('Unauthorized', 'mydash'), 403);
    }
    check_admin_referer('mydash_add_widget', '_mydash_nonce');

    $id         = isset($_POST['id']) ? sanitize_key(wp_unslash($_POST['id'])) : '';
    $title      = isset($_POST['title']) ? sanitize_text_field(wp_unslash($_POST['title'])) : '';
    $type       = isset($_POST['type']) ? sanitize_key(wp_unslash($_POST['type'])) : 'notes';
    $cap        = isset($_POST['capability']) ? sanitize_text_field(wp_unslash($_POST['capability'])) : 'edit_posts';
    $context    = isset($_POST['context']) ? sanitize_key(wp_unslash($_POST['context'])) : 'normal';
    $priority   = isset($_POST['priority']) ? sanitize_key(wp_unslash($_POST['priority'])) : 'core';
    $enabled    = !empty($_POST['enabled']) ? true : false;
    $content    = isset($_POST['content']) ? wp_kses_post(wp_unslash($_POST['content'])) : '';
    $posted_rev = isset($_POST['rev']) ? (int) $_POST['rev'] : 0;

    if (!$id || !$title) {
        wp_safe_redirect(add_query_arg(['page' => 'mydash', 'mydash_msg' => 'invalid'], admin_url('options-general.php')));
        exit;
    }

    $cfg = get_option('mydash_dashboard_widgets', []);
    $cur_rev = (int) ($cfg['rev'] ?? 0);
    if ($posted_rev !== $cur_rev) {
        wp_safe_redirect(add_query_arg(['page' => 'mydash', 'mydash_msg' => 'conflict'], admin_url('options-general.php')));
        exit;
    }

    if (!empty($cfg['widgets'][$id])) {
        wp_safe_redirect(add_query_arg(['page' => 'mydash', 'mydash_msg' => 'exists'], admin_url('options-general.php')));
        exit;
    }

    $cfg['widgets'][$id] = [
        'id'         => $id,
        'slug'       => str_replace('_', '-', $id),
        'title'      => $title,
        'type'       => $type,           // ánh xạ sang callback an toàn ở bước đăng ký widget
        'capability' => $cap,
        'enabled'    => $enabled,
        'context'    => in_array($context, ['normal', 'side'], true) ? $context : 'normal',
        'priority'   => in_array($priority, ['core', 'high', 'default', 'low'], true) ? $priority : 'core',
        'data'       => ['content' => $content],
        'created_at' => gmdate('c'),
        'updated_at' => gmdate('c'),
    ];
    $cfg['rev'] = $cur_rev + 1;

    update_option('mydash_dashboard_widgets', $cfg, false);
    wp_safe_redirect(add_query_arg(['page' => 'mydash', 'mydash_msg' => 'added'], admin_url('options-general.php')));
    exit;
});

add_action('admin_post_mydash_update_widget', function () {
    if (!current_user_can(mydash_cap())) {
        wp_die(esc_html__('Unauthorized', 'mydash'), 403);
    }
    $wid = isset($_POST['wid']) ? sanitize_key(wp_unslash($_POST['wid'])) : '';
    check_admin_referer('mydash_update_widget_' . $wid, '_mydash_nonce');

    $title      = isset($_POST['title']) ? sanitize_text_field(wp_unslash($_POST['title'])) : '';
    $type       = isset($_POST['type']) ? sanitize_key(wp_unslash($_POST['type'])) : 'notes';
    $cap        = isset($_POST['capability']) ? sanitize_text_field(wp_unslash($_POST['capability'])) : 'edit_posts';
    $context    = isset($_POST['context']) ? sanitize_key(wp_unslash($_POST['context'])) : 'normal';
    $priority   = isset($_POST['priority']) ? sanitize_key(wp_unslash($_POST['priority'])) : 'core';
    $enabled    = !empty($_POST['enabled']) ? true : false;
    $content    = isset($_POST['content']) ? wp_kses_post(wp_unslash($_POST['content'])) : '';
    $posted_rev = isset($_POST['rev']) ? (int) $_POST['rev'] : 0;

    $cfg     = get_option('mydash_dashboard_widgets', []);
    $cur_rev = (int) ($cfg['rev'] ?? 0);
    if ($posted_rev !== $cur_rev) {
        wp_safe_redirect(add_query_arg(['page' => 'mydash', 'mydash_msg' => 'conflict'], admin_url('options-general.php')));
        exit;
    }

    if (!$wid || empty($cfg['widgets'][$wid])) {
        wp_safe_redirect(add_query_arg(['page' => 'mydash', 'mydash_msg' => 'notfound'], admin_url('options-general.php')));
        exit;
    }

    // Giới hạn type vào danh sách hỗ trợ
    $allowed_types = ['notes', 'report'];
    if (!in_array($type, $allowed_types, true)) {
        $type = 'notes';
    }

    $w = $cfg['widgets'][$wid];
    $w['title']      = $title ?: ($w['title'] ?? $wid);
    $w['type']       = $type;
    $w['capability'] = $cap ?: 'edit_posts';
    $w['context']    = in_array($context, ['normal', 'side'], true) ? $context : 'normal';
    $w['priority']   = in_array($priority, ['core', 'high', 'default', 'low'], true) ? $priority : 'core';
    $w['enabled']    = $enabled;
    $w['data']['content'] = $content;
    $w['updated_at'] = gmdate('c');

    $cfg['widgets'][$wid] = $w;
    $cfg['rev'] = $cur_rev + 1;

    update_option('mydash_dashboard_widgets', $cfg, false);
    wp_safe_redirect(add_query_arg(['page' => 'mydash', 'mydash_msg' => 'updated'], admin_url('options-general.php')));
    exit;
});

add_action('admin_post_mydash_delete_widget', function () {
    if (!current_user_can(mydash_cap())) {
        wp_die(esc_html__('Unauthorized', 'mydash'), 403);
    }
    $wid = isset($_GET['wid']) ? sanitize_key(wp_unslash($_GET['wid'])) : '';
    check_admin_referer('mydash_delete_widget_' . $wid);

    $posted_rev = isset($_GET['rev']) ? (int) $_GET['rev'] : 0;
    $cfg     = get_option('mydash_dashboard_widgets', []);
    $cur_rev = (int) ($cfg['rev'] ?? 0);
    if ($posted_rev !== $cur_rev) {
        wp_safe_redirect(add_query_arg(['page' => 'mydash', 'mydash_msg' => 'conflict'], admin_url('options-general.php')));
        exit;
    }

    if (!$wid || empty($cfg['widgets'][$wid])) {
        wp_safe_redirect(add_query_arg(['page' => 'mydash', 'mydash_msg' => 'notfound'], admin_url('options-general.php')));
        exit;
    }

    unset($cfg['widgets'][$wid]);
    $cfg['rev'] = $cur_rev + 1;

    update_option('mydash_dashboard_widgets', $cfg, false);
    wp_safe_redirect(add_query_arg(['page' => 'mydash', 'mydash_msg' => 'deleted'], admin_url('options-general.php')));
    exit;
});

// 8) Thông báo admin_notices
add_action('admin_notices', function () {
    if (!is_admin()) {
        return;
    }
    if (!isset($_GET['page']) || $_GET['page'] !== 'mydash') {
        return;
    }
    $msg = isset($_GET['mydash_msg']) ? sanitize_key($_GET['mydash_msg']) : '';
    if (!$msg && empty($_GET['settings-updated'])) {
        return;
    }

    $map = [
        'added'    => ['class' => 'updated', 'text' => __('Đã thêm widget.', 'mydash')],
        'updated'  => ['class' => 'updated', 'text' => __('Đã cập nhật widget.', 'mydash')],
        'deleted'  => ['class' => 'updated', 'text' => __('Đã xóa widget.', 'mydash')],
        'exists'   => ['class' => 'error',   'text' => __('ID đã tồn tại. Chọn ID khác.', 'mydash')],
        'invalid'  => ['class' => 'error',   'text' => __('Thiếu dữ liệu bắt buộc.', 'mydash')],
        'notfound' => ['class' => 'error',   'text' => __('Không tìm thấy widget.', 'mydash')],
        'conflict' => ['class' => 'error',   'text' => __('Xung đột phiên bản. Vui lòng tải lại trang.', 'mydash')],
    ];

    if (!empty($_GET['settings-updated'])) {
        echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__('Đã lưu cài đặt.', 'mydash') . '</p></div>';
        return;
    }

    if (isset($map[$msg])) {
        $c = $map[$msg]['class'] === 'updated' ? 'notice notice-success is-dismissible' : 'notice notice-error';
        echo '<div class="' . esc_attr($c) . '"><p>' . esc_html($map[$msg]['text']) . '</p></div>';
    }
});

Giải thích nhanh cách dùng

  • Vào Settings > MyDash Dashboard để mở trang cấu hình.
  • Khu đầu là checkbox ẩn hiện widget mặc định, lưu bằng Settings API. Mỗi lần lưu, plugin so sánh rev để tránh ghi đè nếu có người khác vừa sửa.
  • Khu thứ hai là bảng liệt kê widget tùy chỉnh cùng hai form:
  • Thêm mới: điền ID, tiêu đề, type, capability, vị trí, ưu tiên, bật tắt, nội dung HTML. Submit sẽ chuyển đến admin-post.php với action mydashaddwidget, có nonce và kiểm tra capability.
  • Sửa: click Sửa ở một dòng để show form sửa với dữ liệu đã điền sẵn. Submit đến action mydashupdatewidget.
  • Xóa: click Xóa sẽ bắn đến action mydashdeletewidget với nonce và xác nhận. Cả ba thao tác đều tăng rev sau khi ghi.

Ví dụ cấu hình mẫu sau khi thêm một widget Team notes thành công

  • Widget được lưu trong option với cấu trúc:
<?php
[
  'schema_version' => 2,
  'rev' => 3,
  'widgets' => [
    'team_notes' => [
      'id'         => 'team_notes',
      'slug'       => 'team-notes',
      'title'      => 'Team notes',
      'type'       => 'notes',
      'capability' => 'edit_posts',
      'enabled'    => true,
      'context'    => 'side',
      'priority'   => 'low',
      'data'       => ['content' => '<p>Welcome to your dashboard</p>'],
      'created_at' => '2026-07-17T10:20:00Z',
      'updated_at' => '2026-07-17T10:20:00Z',
    ],
  ],
  'hidden_core' => [
    'dashboard_primary' => true,
    'dashboard_quick_press' => false,
  ],
  'forced_hide' => [],
  'forced_show' => [],
];

Biến thể có biến số

  • Nếu bạn muốn tự động điền slug khác, hoặc lưu thêm trường data riêng cho type report, cập nhật ở handler mydashaddwidget và mydashupdatewidget bằng cách mở rộng mảng data. Ví dụ:
<?php
$range = isset($_POST['range']) ? sanitize_text_field(wp_unslash($_POST['range'])) : '7d';
$cfg['widgets'][$id]['data']['range'] = $range;

Gợi ý nhỏ để vận hành an toàn

  • Chỉ nhận các type bạn hỗ trợ và ánh xạ type sang callback cố định trong plugin khi đăng ký widget. Không gọi tên hàm từ dữ liệu người dùng.
  • Không ném dữ liệu lớn vào option. Nếu widget cần số liệu thời gian thực, lấy khi render và cache bằng transient riêng.
  • settingsfields đã tạo nonce cho form Settings API. Với form admin-post riêng, bạn luôn có wpnoncefield và checkadmin_referer tương ứng.
  • admin_notices dùng query args để hiển thị thông báo thân thiện cho người dùng nội bộ lẫn khách hàng quản trị. Mã hiển thị đã kiểm tra đúng page để không làm ồn trang khác.

Trang cấu hình này cho bạn một chỗ quản trị thống nhất: bật tắt widget core bằng Settings API và quản lý CRUD widget tùy chỉnh bằng handler riêng có nonce, capability và sanitize chặt chẽ. Khi kết hợp với bước nạp widget ở hook wpdashboardsetup, toàn bộ quy trình sẽ khép kín và dễ bảo trì.

Nạp và đăng ký widget từ cấu hình qua wpdashboardsetup

Mục tiêu là nạp option cấu hình, duyệt danh sách widget tùy chỉnh, ánh xạ type sang callback an toàn, kiểm tra capability theo từng widget trước khi đăng ký, sau đó di chuyển sang đúng cột và ưu tiên hiển thị. Đồng thời, gỡ các widget mặc định theo cấu hình ở priority rất muộn để chắc chắn có hiệu lực.

Các bước thực hiện

  1. Tạo bảng ánh xạ type sang callback cố định của plugin, tuyệt đối không gọi tên hàm lấy từ DB.
  2. Viết callback render cho từng type, nhận args để biết ID widget và lấy dữ liệu từ option.
  3. Viết helper di chuyển meta box sang context và priority mong muốn bằng cách thao tác global $wpmetaboxes ở priority 99.
  4. Viết hàm đăng ký widget chạy trên wpdashboardsetup để duyệt option, kiểm tra capability và gọi wpadddashboard_widget với callback và args đã ánh xạ.
  5. Viết hàm gỡ widget mặc định dựa trên hiddencore trong option, dùng removemetabox ở priority PHPINTMAX. Với Welcome panel thì removeaction welcome_panel.

Mẫu hoàn chỉnh có thể dán vào file plugin hiện có

<?php
// 1) Ánh xạ type -> callback an toàn
function mydash_widget_callback_map(): array {
    return [
        'notes'  => 'mydash_cb_render_notes',
        'report' => 'mydash_cb_render_report',
    ];
}

// 2) Callback render cho type "notes"
function mydash_cb_render_notes($post = null, $box = []) {
    $args = isset($box['args']) && is_array($box['args']) ? $box['args'] : [];
    $wid  = isset($args['id']) ? sanitize_key($args['id']) : '';

    $cfg = get_option('mydash_dashboard_widgets', []);
    $w   = isset($cfg['widgets'][$wid]) ? $cfg['widgets'][$wid] : null;
    if (!$w) {
        echo '<p>' . esc_html__('Không tìm thấy dữ liệu widget.', 'mydash') . '</p>';
        return;
    }
    if (!current_user_can($w['capability'] ?? 'read')) {
        echo '<p>' . esc_html__('Bạn không có quyền xem widget này.', 'mydash') . '</p>';
        return;
    }

    $content = isset($w['data']['content']) ? $w['data']['content'] : '';
    echo wp_kses_post($content);
}

// 2) Callback render cho type "report" - ví dụ placeholder
function mydash_cb_render_report($post = null, $box = []) {
    $args = isset($box['args']) && is_array($box['args']) ? $box['args'] : [];
    $wid  = isset($args['id']) ? sanitize_key($args['id']) : '';

    $cfg = get_option('mydash_dashboard_widgets', []);
    $w   = isset($cfg['widgets'][$wid]) ? $cfg['widgets'][$wid] : null;
    if (!$w) {
        echo '<p>' . esc_html__('Không tìm thấy dữ liệu widget.', 'mydash') . '</p>';
        return;
    }
    if (!current_user_can($w['capability'] ?? 'read')) {
        echo '<p>' . esc_html__('Bạn không có quyền xem widget này.', 'mydash') . '</p>';
        return;
    }

    $range = isset($w['data']['range']) ? sanitize_text_field($w['data']['range']) : '7d';
    echo '<p>' . sprintf(esc_html__('Báo cáo tóm tắt, khoảng thời gian: %s', 'mydash'), esc_html($range)) . '</p>';
    echo '<ul><li>' . esc_html__('Số liệu A: 123', 'mydash') . '</li><li>' . esc_html__('Số liệu B: 456', 'mydash') . '</li></ul>';
}

// 3) Helper di chuyển widget sang context và priority mong muốn
function mydash_place_widget(string $widget_id, string $dst_context, string $dst_priority, string $screen = 'dashboard'): void {
    global $wp_meta_boxes;

    $contexts   = ['normal', 'side'];
    $priorities = ['core', 'default', 'high', 'low'];

    $box = null;
    foreach ($contexts as $ctx) {
        foreach ($priorities as $pri) {
            if (isset($wp_meta_boxes[$screen][$ctx][$pri][$widget_id])) {
                $box = $wp_meta_boxes[$screen][$ctx][$pri][$widget_id];
                unset($wp_meta_boxes[$screen][$ctx][$pri][$widget_id]);
                break 2;
            }
        }
    }

    if (!$box) {
        return;
    }

    // Bảo đảm mảng đích tồn tại
    if (!isset($wp_meta_boxes[$screen][$dst_context])) {
        $wp_meta_boxes[$screen][$dst_context] = [];
    }
    if (!isset($wp_meta_boxes[$screen][$dst_context][$dst_priority])) {
        $wp_meta_boxes[$screen][$dst_context][$dst_priority] = [];
    }

    // Cập nhật metadata box
    $box['context']  = $dst_context;
    $box['priority'] = $dst_priority;

    // Đặt vào bucket đích, không ghi vào 'sorted'
    $wp_meta_boxes[$screen][$dst_context][$dst_priority][$widget_id] = $box;
}

// 4) Đăng ký widget từ option ở priority 99, đồng thời di chuyển vị trí
function mydash_register_dashboard_widgets(): void {
    // Chỉ chạy trên trang Dashboard site admin
    if (!is_admin() || !function_exists('get_current_screen')) {
        return;
    }
    $screen = get_current_screen();
    if (!$screen || $screen->id !== 'dashboard') {
        return;
    }

    $cfg = get_option('mydash_dashboard_widgets', []);
    $list = isset($cfg['widgets']) && is_array($cfg['widgets']) ? $cfg['widgets'] : [];
    if (!$list) {
        return;
    }

    $cb_map = mydash_widget_callback_map();

    foreach ($list as $id => $w) {
        // Bật tắt
        if (empty($w['enabled'])) {
            continue;
        }

        // Kiểm tra type hỗ trợ
        $type = isset($w['type']) ? sanitize_key($w['type']) : '';
        if (!$type || !isset($cb_map[$type])) {
            continue;
        }

        // Kiểm tra capability trước khi add
        $cap = isset($w['capability']) && is_string($w['capability']) ? $w['capability'] : 'read';
        if (!current_user_can($cap)) {
            continue;
        }

        // Chuẩn hóa tham số vị trí mong muốn
        $ctx = in_array($w['context'] ?? 'normal', ['normal', 'side'], true) ? $w['context'] : 'normal';
        $pri = in_array($w['priority'] ?? 'core', ['core', 'default', 'high', 'low'], true) ? $w['priority'] : 'core';

        // Đăng ký widget, callback args kèm ID để callback lấy dữ liệu
        $title = $w['title'] ?? $id;
        $cb    = $cb_map[$type];

        wp_add_dashboard_widget(
            $id,
            $title,
            $cb,
            null,
            ['id' => $id]
        );

        // Di chuyển sang context - priority theo cấu hình
        mydash_place_widget($id, $ctx, $pri, 'dashboard');
    }
}
add_action('wp_dashboard_setup', 'mydash_register_dashboard_widgets', 99);

// 5) Gỡ widget mặc định theo hidden_core ở priority rất muộn
function mydash_hide_core_dashboard_widgets(): void {
    // Chỉ áp dụng cho trang Dashboard site admin
    if (!is_admin() || !function_exists('get_current_screen')) {
        return;
    }
    $screen = get_current_screen();
    if (!$screen || $screen->id !== 'dashboard') {
        return;
    }

    $cfg = get_option('mydash_dashboard_widgets', []);
    $hidden = isset($cfg['hidden_core']) && is_array($cfg['hidden_core']) ? $cfg['hidden_core'] : [];
    if (!$hidden) {
        return;
    }

    foreach ($hidden as $id => $hide) {
        if (!$hide) {
            continue;
        }
        // Welcome panel không phải meta box
        if ($id === 'welcome_panel') {
            remove_action('welcome_panel', 'wp_welcome_panel');
            continue;
        }
        // Không chắc context, gỡ ở cả normal và side
        remove_meta_box($id, 'dashboard', 'normal');
        remove_meta_box($id, 'dashboard', 'side');
    }
}
add_action('wp_dashboard_setup', 'mydash_hide_core_dashboard_widgets', PHP_INT_MAX);

Giải thích nhanh để bạn vận hành

  • Callback map mydashwidgetcallback_map là lớp bảo vệ quan trọng để không bao giờ thực thi hàm tùy ý lấy từ DB. type trong option chỉ là khóa để tra map.
  • currentusercan được kiểm tra cho từng widget trước khi wpadddashboard_widget. Callback cũng kiểm tra lại để an toàn phòng trường hợp type được đổi cấu hình mà user vẫn còn DOM của widget cũ trong phiên.
  • Di chuyển vị trí diễn ra ngay sau khi đăng ký, ở cùng priority 99. Hàm mydashplacewidget chỉ ghi vào bucket context – priority, không đụng bucket sorted nên không can thiệp thứ tự người dùng đã lưu. Nếu người dùng đã từng kéo thả, WordPress sẽ ưu tiên order cá nhân của họ và bỏ qua bucket mặc định bạn đặt.
  • Gỡ widget mặc định chạy ở PHPINTMAX để chắc chắn sau tất cả nguồn đăng ký khác. removemetabox gọi cho cả normal và side để không cần đoán context. Với Welcome panel, dùng removeaction welcomepanel vì nó không phải meta box.
  • Hiệu năng ổn vì chỉ đọc một option và không truy vấn nặng khi đăng ký. Nội dung tốn tài nguyên hãy tính trong callback và có thể cache bằng transient riêng nếu cần.

Biến thể đa site, nếu bạn cần áp dụng tương tự ở Network Admin hoặc User Admin

  • Thêm các hook tương đương và thay screen id khi di chuyển:
<?php
add_action('wp_network_dashboard_setup', function () {
    // Kiểm tra quyền mạng phù hợp trước khi add
    // Đăng ký widget mạng của bạn...
    // Di chuyển với mydash_place_widget($id, $ctx, $pri, 'dashboard-network');
});

add_action('wp_user_dashboard_setup', function () {
    // Đăng ký widget cho User Admin...
    // Di chuyển với mydash_place_widget($id, $ctx, $pri, 'dashboard-user');
});

Khi ghép phần này vào plugin ở phần trước, bạn đã có quy trình nạp cấu hình, đăng ký widget tùy chỉnh có kiểm tra capability, định vị đúng cột và ưu tiên hiển thị, đồng thời ẩn widget mặc định theo chính sách một cách đáng tin cậy.

Thêm widget tùy chỉnh: schema, form tạo và đăng ký

Bạn cần một quy trình chuẩn để thêm widget mới: định nghĩa schema, tạo form nhập, validate và sanitize đầu vào, lưu vào option với rev tăng dần, ánh xạ type sang callback render và control, rồi đăng ký widget khi nạp Dashboard và đặt nó vào đúng cột – ưu tiên. Dưới đây là gói mã bạn có thể dán vào plugin đã dựng ở các phần trước.

Các bước thực hiện

  1. Khai báo type registry và callback cho widget mẫu html
  2. Tạo form thêm widget trong trang cài đặt của plugin
  3. Xử lý submit: validate, sanitize, lưu option và tăng rev
  4. Đảm bảo wpdashboardsetup đăng ký widget và đặt vị trí mong muốn
  1. Type registry và callback cho widget html
  • Type html cho phép nhập nội dung HTML, hiển thị bằng wpksespost, có control form con để sửa nhanh ngay trên widget.
<?php
// Registry: định nghĩa type -> render, control
function mydash_widget_type_registry(): array {
    return [
        'html' => [
            'label'   => 'HTML note',
            'render'  => 'mydash_cb_render_html',
            'control' => 'mydash_cb_control_html',
        ],
        // Bạn có thể thêm các type khác tại đây
    ];
}

// Bổ sung 'html' vào map render nếu bạn đang dùng mydash_widget_callback_map() ở phần trước
function mydash_widget_callback_map(): array {
    return [
        'notes'  => 'mydash_cb_render_notes',
        'report' => 'mydash_cb_render_report',
        'html'   => 'mydash_cb_render_html',
    ];
}

// Render: in nội dung đã kses
function mydash_cb_render_html($post = null, $box = []) {
    $args = isset($box['args']) && is_array($box['args']) ? $box['args'] : [];
    $wid  = isset($args['id']) ? sanitize_key($args['id']) : '';

    $cfg = get_option('mydash_dashboard_widgets', []);
    $w   = isset($cfg['widgets'][$wid]) ? $cfg['widgets'][$wid] : null;

    if (!$w) {
        echo '<p>' . esc_html__('Không tìm thấy dữ liệu widget.', 'mydash') . '</p>';
        return;
    }
    if (!current_user_can($w['capability'] ?? 'read')) {
        echo '<p>' . esc_html__('Bạn không có quyền xem widget này.', 'mydash') . '</p>';
        return;
    }

    $content = isset($w['data']['content']) ? (string) $w['data']['content'] : '';
    echo wp_kses_post($content);
}

// Control: form con để cập nhật nội dung nhanh trên Dashboard
function mydash_cb_control_html($post = null, $box = []) {
    $args = isset($box['args']) && is_array($box['args']) ? $box['args'] : [];
    $wid  = isset($args['id']) ? sanitize_key($args['id']) : '';

    $cfg = get_option('mydash_dashboard_widgets', []);
    $w   = isset($cfg['widgets'][$wid]) ? $cfg['widgets'][$wid] : null;
    if (!$w) {
        echo '<p>' . esc_html__('Không tìm thấy dữ liệu widget.', 'mydash') . '</p>';
        return;
    }
    if (!current_user_can('manage_options')) { // quyền chỉnh cấu hình plugin
        echo '<p>' . esc_html__('Bạn không có quyền chỉnh widget.', 'mydash') . '</p>';
        return;
    }

    $rev = intval($cfg['rev'] ?? 0);
    $content = isset($w['data']['content']) ? (string) $w['data']['content'] : '';
    ?>
    <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
        <input type="hidden" name="action" value="mydash_update_widget_inline">
        <input type="hidden" name="id" value="<?php echo esc_attr($wid); ?>">
        <input type="hidden" name="rev" value="<?php echo esc_attr($rev); ?>">
        <?php wp_nonce_field('mydash_update_widget_inline', 'mydash_nonce'); ?>
        <p>
            <label for="mydash_content_<?php echo esc_attr($wid); ?>">
                <?php esc_html_e('Nội dung HTML', 'mydash'); ?>
            </label>
            <textarea id="mydash_content_<?php echo esc_attr($wid); ?>" name="data[content]" class="widefat" rows="5"><?php echo esc_textarea($content); ?></textarea>
        </p>
        <p>
            <button type="submit" class="button button-primary"><?php esc_html_e('Lưu', 'mydash'); ?></button>
        </p>
    </form>
    <?php
}

// Handler lưu nhanh từ control
add_action('admin_post_mydash_update_widget_inline', function () {
    if (!current_user_can('manage_options')) {
        wp_die(esc_html__('Unauthorized', 'mydash'), 403);
    }
    check_admin_referer('mydash_update_widget_inline', 'mydash_nonce');

    $id  = isset($_POST['id']) ? sanitize_key(wp_unslash($_POST['id'])) : '';
    $rev = isset($_POST['rev']) ? intval($_POST['rev']) : 0;
    $cfg = get_option('mydash_dashboard_widgets', []);
    $cur = intval($cfg['rev'] ?? 0);

    if (!$id || !isset($cfg['widgets'][$id])) {
        wp_die(esc_html__('Widget không tồn tại.', 'mydash'), 400);
    }
    if ($rev !== $cur) {
        wp_safe_redirect(add_query_arg('mydash_err', 'conflict', admin_url('index.php')));
        exit;
    }

    $content = '';
    if (isset($_POST['data']['content'])) {
        $content = wp_kses_post(wp_unslash($_POST['data']['content']));
    }

    $cfg['widgets'][$id]['data']['content'] = $content;
    $cfg['widgets'][$id]['updated_at'] = gmdate('c');
    $cfg['rev'] = $cur + 1;

    update_option('mydash_dashboard_widgets', $cfg, false);
    wp_safe_redirect(add_query_arg('mydash_updated', 1, admin_url('index.php')));
    exit;
});
  1. Form thêm widget trong trang cài đặt
  • Form này thêm một record widget mới với id, title, capability, type, data, context, priority. Ví dụ dưới đây mặc định chọn cột side và priority low.
<?php
// Trong trang settings của plugin
$cfg = get_option('mydash_dashboard_widgets', []);
$rev = intval($cfg['rev'] ?? 0);
$types = mydash_widget_type_registry();
?>
<h2><?php esc_html_e('Thêm widget Dashboard mới', 'mydash'); ?></h2>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
    <input type="hidden" name="action" value="mydash_add_widget">
    <input type="hidden" name="rev" value="<?php echo esc_attr($rev); ?>">
    <?php wp_nonce_field('mydash_add_widget', 'mydash_nonce'); ?>

    <table class="form-table" role="presentation">
        <tr>
            <th scope="row"><label for="mydash_id"><?php esc_html_e('ID', 'mydash'); ?></label></th>
            <td>
                <input type="text" id="mydash_id" name="id" class="regular-text" required pattern="[a-z0-9_]+" placeholder="mydash_html_note">
                <p class="description"><?php esc_html_e('Chỉ chữ thường, số, gạch dưới. Phải duy nhất.', 'mydash'); ?></p>
            </td>
        </tr>
        <tr>
            <th scope="row"><label for="mydash_title"><?php esc_html_e('Tiêu đề', 'mydash'); ?></label></th>
            <td><input type="text" id="mydash_title" name="title" class="regular-text" required></td>
        </tr>
        <tr>
            <th scope="row"><label for="mydash_cap"><?php esc_html_e('Capability để xem', 'mydash'); ?></label></th>
            <td><input type="text" id="mydash_cap" name="capability" class="regular-text" value="read"></td>
        </tr>
        <tr>
            <th scope="row"><label for="mydash_type"><?php esc_html_e('Loại widget', 'mydash'); ?></label></th>
            <td>
                <select id="mydash_type" name="type">
                    <?php foreach ($types as $k => $def): ?>
                        <option value="<?php echo esc_attr($k); ?>"><?php echo esc_html($def['label']); ?></option>
                    <?php endforeach; ?>
                </select>
            </td>
        </tr>
        <tr>
            <th scope="row"><?php esc_html_e('Vị trí', 'mydash'); ?></th>
            <td>
                <label><input type="radio" name="context" value="normal"> normal</label>
                <label><input type="radio" name="context" value="side" checked> side</label>
                <span style="margin-left:1em;">
                    <label><?php esc_html_e('Priority', 'mydash'); ?>
                        <select name="priority">
                            <option value="low" selected>low</option>
                            <option value="default">default</option>
                            <option value="core">core</option>
                            <option value="high">high</option>
                        </select>
                    </label>
                </span>
            </td>
        </tr>
        <tr>
            <th scope="row"><label for="mydash_data_content"><?php esc_html_e('Nội dung HTML', 'mydash'); ?></label></th>
            <td>
                <textarea id="mydash_data_content" name="data[content]" class="large-text code" rows="6"><p><strong>Xin chào</strong>, đây là ghi chú HTML.</p></textarea>
                <p class="description"><?php esc_html_e('Áp dụng cho type html. Nội dung sẽ được lọc an toàn.', 'mydash'); ?></p>
            </td>
        </tr>
    </table>

    <p><button type="submit" class="button button-primary"><?php esc_html_e('Thêm widget', 'mydash'); ?></button></p>
</form>
  1. Xử lý submit: validate, sanitize, lưu option và tăng rev
  • Mình dùng admin_post để xử lý, khóa lạc quan bằng rev để tránh ghi đè đồng thời.
<?php
add_action('admin_post_mydash_add_widget', 'mydash_handle_add_widget');
function mydash_handle_add_widget() {
    if (!current_user_can('manage_options')) {
        wp_die(esc_html__('Unauthorized', 'mydash'), 403);
    }
    check_admin_referer('mydash_add_widget', 'mydash_nonce');

    $id  = isset($_POST['id']) ? sanitize_key(wp_unslash($_POST['id'])) : '';
    $title = isset($_POST['title']) ? sanitize_text_field(wp_unslash($_POST['title'])) : '';
    $cap = isset($_POST['capability']) ? sanitize_key(wp_unslash($_POST['capability'])) : 'read';
    $type = isset($_POST['type']) ? sanitize_key(wp_unslash($_POST['type'])) : '';
    $context = isset($_POST['context']) && in_array($_POST['context'], ['normal','side'], true)
        ? $_POST['context'] : 'normal';
    $priority = isset($_POST['priority']) && in_array($_POST['priority'], ['core','default','high','low'], true)
        ? $_POST['priority'] : 'core';
    $rev = isset($_POST['rev']) ? intval($_POST['rev']) : 0;

    $types = mydash_widget_type_registry();
    if (!$id || !$title || !$type || !isset($types[$type])) {
        wp_safe_redirect(add_query_arg('mydash_err', 'invalid', admin_url('options-general.php?page=mydash')));
        exit;
    }

    // Sanitize data theo type
    $data = [];
    if ($type === 'html') {
        $raw = isset($_POST['data']['content']) ? wp_unslash($_POST['data']['content']) : '';
        $data['content'] = wp_kses_post($raw);
    }

    // Đọc và kiểm tra rev
    $cfg = get_option('mydash_dashboard_widgets', []);
    $cur = intval($cfg['rev'] ?? 0);
    if ($rev !== $cur) {
        wp_safe_redirect(add_query_arg('mydash_err', 'conflict', admin_url('options-general.php?page=mydash')));
        exit;
    }

    // Khởi tạo khung option nếu trống
    if (!isset($cfg['widgets']) || !is_array($cfg['widgets'])) {
        $cfg['widgets'] = [];
    }

    // Duy nhất ID
    if (isset($cfg['widgets'][$id])) {
        wp_safe_redirect(add_query_arg('mydash_err', 'duplicate', admin_url('options-general.php?page=mydash')));
        exit;
    }

    $cfg['widgets'][$id] = [
        'id'         => $id,
        'title'      => $title,
        'enabled'    => true,
        'context'    => $context,
        'priority'   => $priority,
        'capability' => $cap ?: 'read',
        'type'       => $type,
        'data'       => $data,
        'updated_at' => gmdate('c'),
    ];

    $cfg['rev'] = $cur + 1;

    update_option('mydash_dashboard_widgets', $cfg, false);
    wp_safe_redirect(add_query_arg(['mydash_updated' => 1], admin_url('options-general.php?page=mydash')));
    exit;
}
  1. Đăng ký widget khi nạp Dashboard và đặt vị trí
  • Nếu bạn đã có hàm đăng ký từ phần trước, chỉ cần bảo đảm map có type html và, nếu có control callback, truyền vào wpadddashboard_widget. Ví dụ đoạn thay thế cho phần add:
<?php
function mydash_register_dashboard_widgets(): void {
    if (!function_exists('get_current_screen')) {
        return;
    }
    $screen = get_current_screen();
    if (!$screen || $screen->id !== 'dashboard') {
        return;
    }

    $cfg = get_option('mydash_dashboard_widgets', []);
    $list = isset($cfg['widgets']) && is_array($cfg['widgets']) ? $cfg['widgets'] : [];
    if (!$list) {
        return;
    }

    $type_reg = mydash_widget_type_registry();

    foreach ($list as $id => $w) {
        if (empty($w['enabled'])) {
            continue;
        }
        $type = isset($w['type']) ? sanitize_key($w['type']) : '';
        if (!$type || !isset($type_reg[$type])) {
            continue;
        }
        $cap = isset($w['capability']) ? (string) $w['capability'] : 'read';
        if (!current_user_can($cap)) {
            continue;
        }

        $title = $w['title'] ?? $id;
        $render_cb  = $type_reg[$type]['render'];
        $control_cb = isset($type_reg[$type]['control']) ? $type_reg[$type]['control'] : null;

        wp_add_dashboard_widget(
            $id,
            $title,
            $render_cb,
            $control_cb,
            ['id' => $id]
        );

        // Đặt vào cột - ưu tiên mong muốn, ví dụ side + low
        $ctx = in_array($w['context'] ?? 'normal', ['normal','side'], true) ? $w['context'] : 'normal';
        $pri = in_array($w['priority'] ?? 'core', ['core','default','high','low'], true) ? $w['priority'] : 'core';
        mydash_place_widget($id, $ctx, $pri, 'dashboard');
    }
}
add_action('wp_dashboard_setup', 'mydash_register_dashboard_widgets', 99);

Kết quả mong đợi

  • Bạn tạo một widget type html với ví dụ nội dung, lưu vào option với rev tăng dần, và khi vào Dashboard nó sẽ hiển thị HTML đã được wpksespost. Với cấu hình mẫu ở form, widget sẽ nằm ở cột side với priority low mặc định. Nếu người dùng đã tự sắp xếp, WordPress sẽ tôn trọng thứ tự của họ.

Sửa widget tùy chỉnh: cập nhật an toàn và kiểm soát thay đổi

Mình sẽ triển khai luồng sửa một widget đã có theo hướng an toàn: nạp dữ liệu cũ vào form với escape đúng chỗ, khóa lạc quan bằng rev để phát hiện xung đột, chỉ cho phép sửa các trường an toàn như title, capability, context, priority và payload data, còn type và callback luôn bị giới hạn trong registry nội bộ của plugin. Khi lưu sẽ merge thay đổi, tăng rev, cập nhật option và phản hồi rõ ràng cho người dùng.

Các bước thực hiện

  1. Tạo form sửa, đổ sẵn dữ liệu cũ với escattr và esctextarea.
  2. Xử lý submit: kiểm tra nonce và capability, đối chiếu rev để phát hiện xung đột, merge thay đổi theo whitelist, tăng rev rồi update_option.
  3. Hiển thị thông báo thành công hoặc lỗi để người dùng hiểu chuyện gì đã xảy ra.

Ví dụ hoàn chỉnh: form sửa một widget cụ thể Đây là hàm in form sửa, chỉ định thẳng ID mydashhtmlnote. Bạn có thể dán vào trang Settings của plugin để có một form chạy ngay.

<?php
// In form sửa cho một widget cố định 'mydash_html_note'
function mydash_render_edit_widget_form_fixed(): void {
    $cfg = get_option('mydash_dashboard_widgets', []);
    $rev = intval($cfg['rev'] ?? 0);

    $id = 'mydash_html_note';
    $w  = isset($cfg['widgets'][$id]) && is_array($cfg['widgets'][$id]) ? $cfg['widgets'][$id] : null;
    if (!$w) {
        echo '<div class="notice notice-error"><p>' . esc_html__('Widget không tồn tại: mydash_html_note', 'mydash') . '</p></div>';
        return;
    }

    $title = (string) ($w['title'] ?? $id);
    $cap   = (string) ($w['capability'] ?? 'read');
    $type  = (string) ($w['type'] ?? 'html'); // chỉ hiển thị, không cho sửa
    $ctx   = in_array($w['context'] ?? 'normal', ['normal', 'side'], true) ? $w['context'] : 'normal';
    $pri   = in_array($w['priority'] ?? 'core', ['core','default','high','low'], true) ? $w['priority'] : 'core';
    $content = isset($w['data']['content']) ? (string) $w['data']['content'] : '';
    ?>
    <h2><?php esc_html_e('Sửa widget: mydash_html_note', 'mydash'); ?></h2>
    <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
        <input type="hidden" name="action" value="mydash_update_widget">
        <input type="hidden" name="id" value="<?php echo esc_attr($id); ?>">
        <input type="hidden" name="rev" value="<?php echo esc_attr($rev); ?>">
        <?php wp_nonce_field('mydash_update_widget', 'mydash_nonce'); ?>

        <table class="form-table" role="presentation">
            <tr>
                <th scope="row"><label for="mydash_title"><?php esc_html_e('Tiêu đề', 'mydash'); ?></label></th>
                <td><input type="text" id="mydash_title" name="title" class="regular-text" required value="<?php echo esc_attr($title); ?>"></td>
            </tr>
            <tr>
                <th scope="row"><label for="mydash_cap"><?php esc_html_e('Capability để xem', 'mydash'); ?></label></th>
                <td><input type="text" id="mydash_cap" name="capability" class="regular-text" value="<?php echo esc_attr($cap); ?>"></td>
            </tr>
            <tr>
                <th scope="row"><?php esc_html_e('Loại widget', 'mydash'); ?></th>
                <td>
                    <input type="text" class="regular-text" value="<?php echo esc_attr($type); ?>" readonly>
                    <p class="description"><?php esc_html_e('Type được giới hạn bởi plugin và không thể thay đổi tại đây.', 'mydash'); ?></p>
                </td>
            </tr>
            <tr>
                <th scope="row"><?php esc_html_e('Vị trí', 'mydash'); ?></th>
                <td>
                    <label><input type="radio" name="context" value="normal" <?php checked('normal', $ctx); ?>> normal</label>
                    <label style="margin-left:1em;"><input type="radio" name="context" value="side" <?php checked('side', $ctx); ?>> side</label>
                    <span style="margin-left:1em;">
                        <label><?php esc_html_e('Priority', 'mydash'); ?>
                            <select name="priority">
                                <option value="low" <?php selected('low', $pri); ?>>low</option>
                                <option value="default" <?php selected('default', $pri); ?>>default</option>
                                <option value="core" <?php selected('core', $pri); ?>>core</option>
                                <option value="high" <?php selected('high', $pri); ?>>high</option>
                            </select>
                        </label>
                    </span>
                </td>
            </tr>
            <?php if ($type === 'html'): ?>
                <tr>
                    <th scope="row"><label for="mydash_data_content"><?php esc_html_e('Nội dung HTML', 'mydash'); ?></label></th>
                    <td>
                        <textarea id="mydash_data_content" name="data[content]" class="large-text code" rows="8"><?php echo esc_textarea($content); ?></textarea>
                        <p class="description"><?php esc_html_e('Nội dung sẽ được lọc an toàn trước khi lưu.', 'mydash'); ?></p>
                    </td>
                </tr>
            <?php endif; ?>
        </table>

        <p><button type="submit" class="button button-primary"><?php esc_html_e('Lưu thay đổi', 'mydash'); ?></button></p>
    </form>
    <?php
}

Biến thể tổng quát: in form cho bất kỳ ID từ query Đoạn sau đọc tham số edit từ URL và in form tương ứng.

<?php
// Ví dụ gọi ở trang Settings của plugin
if (isset($_GET['edit'])) {
    $id = sanitize_key(wp_unslash($_GET['edit']));
    if ($id) {
        // Tái sử dụng logic ở hàm cố định bằng cách bóc tách ra hàm tổng quát
        mydash_render_edit_widget_form($id);
    }
}

// Bản tổng quát của hàm in form
function mydash_render_edit_widget_form(string $id): void {
    $cfg = get_option('mydash_dashboard_widgets', []);
    $rev = intval($cfg['rev'] ?? 0);

    $w  = isset($cfg['widgets'][$id]) && is_array($cfg['widgets'][$id]) ? $cfg['widgets'][$id] : null;
    if (!$w) {
        echo '<div class="notice notice-error"><p>' . esc_html(sprintf(__('Widget không tồn tại: %s', 'mydash'), $id)) . '</p></div>';
        return;
    }

    $title = (string) ($w['title'] ?? $id);
    $cap   = (string) ($w['capability'] ?? 'read');
    $type  = (string) ($w['type'] ?? '');
    $ctx   = in_array($w['context'] ?? 'normal', ['normal', 'side'], true) ? $w['context'] : 'normal';
    $pri   = in_array($w['priority'] ?? 'core', ['core','default','high','low'], true) ? $w['priority'] : 'core';

    $html_content = '';
    if ($type === 'html') {
        $html_content = isset($w['data']['content']) ? (string) $w['data']['content'] : '';
    }

    ?>
    <h2><?php echo esc_html(sprintf(__('Sửa widget: %s', 'mydash'), $id)); ?></h2>
    <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
        <input type="hidden" name="action" value="mydash_update_widget">
        <input type="hidden" name="id" value="<?php echo esc_attr($id); ?>">
        <input type="hidden" name="rev" value="<?php echo esc_attr($rev); ?>">
        <?php wp_nonce_field('mydash_update_widget', 'mydash_nonce'); ?>

        <table class="form-table" role="presentation">
            <tr>
                <th scope="row"><label for="mydash_title"><?php esc_html_e('Tiêu đề', 'mydash'); ?></label></th>
                <td><input type="text" id="mydash_title" name="title" class="regular-text" required value="<?php echo esc_attr($title); ?>"></td>
            </tr>
            <tr>
                <th scope="row"><label for="mydash_cap"><?php esc_html_e('Capability để xem', 'mydash'); ?></label></th>
                <td><input type="text" id="mydash_cap" name="capability" class="regular-text" value="<?php echo esc_attr($cap); ?>"></td>
            </tr>
            <tr>
                <th scope="row"><?php esc_html_e('Loại widget', 'mydash'); ?></th>
                <td>
                    <input type="text" class="regular-text" value="<?php echo esc_attr($type); ?>" readonly>
                    <p class="description"><?php esc_html_e('Type bị giới hạn trong danh sách được hỗ trợ và không nhận từ người dùng.', 'mydash'); ?></p>
                </td>
            </tr>
            <tr>
                <th scope="row"><?php esc_html_e('Vị trí', 'mydash'); ?></th>
                <td>
                    <label><input type="radio" name="context" value="normal" <?php checked('normal', $ctx); ?>> normal</label>
                    <label style="margin-left:1em;"><input type="radio" name="context" value="side" <?php checked('side', $ctx); ?>> side</label>
                    <span style="margin-left:1em;">
                        <label><?php esc_html_e('Priority', 'mydash'); ?>
                            <select name="priority">
                                <option value="low" <?php selected('low', $pri); ?>>low</option>
                                <option value="default" <?php selected('default', $pri); ?>>default</option>
                                <option value="core" <?php selected('core', $pri); ?>>core</option>
                                <option value="high" <?php selected('high', $pri); ?>>high</option>
                            </select>
                        </label>
                    </span>
                </td>
            </tr>
            <?php if ($type === 'html'): ?>
                <tr>
                    <th scope="row"><label for="mydash_data_content"><?php esc_html_e('Nội dung HTML', 'mydash'); ?></label></th>
                    <td>
                        <textarea id="mydash_data_content" name="data[content]" class="large-text code" rows="8"><?php echo esc_textarea($html_content); ?></textarea>
                    </td>
                </tr>
            <?php endif; ?>
        </table>

        <p><button type="submit" class="button button-primary"><?php esc_html_e('Lưu thay đổi', 'mydash'); ?></button></p>
    </form>
    <?php
}

Xử lý submit: kiểm tra rev, merge thay đổi, tăng rev và phản hồi Đoạn handler sau đảm bảo chỉ sửa trường hợp pháp, kiểm soát xung đột bằng rev và không bao giờ nhận callback từ người dùng.

<?php
add_action('admin_post_mydash_update_widget', 'mydash_handle_update_widget');
function mydash_handle_update_widget(): void {
    if (!current_user_can('manage_options')) {
        wp_die(esc_html__('Unauthorized', 'mydash'), 403);
    }
    check_admin_referer('mydash_update_widget', 'mydash_nonce');

    $id   = isset($_POST['id']) ? sanitize_key(wp_unslash($_POST['id'])) : '';
    $rev  = isset($_POST['rev']) ? intval($_POST['rev']) : 0;

    if (!$id) {
        wp_safe_redirect(add_query_arg('mydash_err', 'invalid_id', admin_url('options-general.php?page=mydash')));
        exit;
    }

    $cfg = get_option('mydash_dashboard_widgets', []);
    $cur = intval($cfg['rev'] ?? 0);
    if (!isset($cfg['widgets'][$id])) {
        wp_safe_redirect(add_query_arg('mydash_err', 'not_found', admin_url('options-general.php?page=mydash&edit=' . urlencode($id))));
        exit;
    }

    // Khóa lạc quan: phát hiện xung đột chỉnh sửa
    if ($rev !== $cur) {
        wp_safe_redirect(add_query_arg('mydash_err', 'conflict', admin_url('options-general.php?page=mydash&edit=' . urlencode($id))));
        exit;
    }

    $w = $cfg['widgets'][$id];

    // Chỉ cho sửa các trường an toàn
    $title = isset($_POST['title']) ? sanitize_text_field(wp_unslash($_POST['title'])) : ($w['title'] ?? $id);
    $cap   = isset($_POST['capability']) ? sanitize_key(wp_unslash($_POST['capability'])) : ($w['capability'] ?? 'read');

    $ctx = isset($_POST['context']) && in_array($_POST['context'], ['normal','side'], true)
        ? $_POST['context'] : ($w['context'] ?? 'normal');

    $pri = isset($_POST['priority']) && in_array($_POST['priority'], ['core','default','high','low'], true)
        ? $_POST['priority'] : ($w['priority'] ?? 'core');

    // Type không nhận từ người dùng, lấy từ bản ghi cũ và kiểm tra trong registry
    $type = isset($w['type']) ? sanitize_key($w['type']) : '';
    $reg  = function_exists('mydash_widget_type_registry') ? mydash_widget_type_registry() : [];
    if (!$type || !isset($reg[$type])) {
        wp_safe_redirect(add_query_arg('mydash_err', 'unsupported_type', admin_url('options-general.php?page=mydash&edit=' . urlencode($id))));
        exit;
    }

    // Sanitize payload theo type
    $data = isset($w['data']) && is_array($w['data']) ? $w['data'] : [];
    if ($type === 'html') {
        $raw = isset($_POST['data']['content']) ? wp_unslash($_POST['data']['content']) : '';
        $data['content'] = wp_kses_post($raw);
    }

    // Merge thay đổi và tăng rev
    $cfg['widgets'][$id]['title']      = $title;
    $cfg['widgets'][$id]['capability'] = $cap ?: 'read';
    $cfg['widgets'][$id]['context']    = $ctx;
    $cfg['widgets'][$id]['priority']   = $pri;
    $cfg['widgets'][$id]['data']       = $data;
    $cfg['widgets'][$id]['updated_at'] = gmdate('c');

    $cfg['rev'] = $cur + 1;

    update_option('mydash_dashboard_widgets', $cfg, false);

    // Phản hồi rõ ràng
    $url = add_query_arg(
        ['mydash_updated' => 1, 'edited' => $id],
        admin_url('options-general.php?page=mydash')
    );
    wp_safe_redirect($url);
    exit;
}

Thông báo rõ ràng sau khi lưu hoặc khi xung đột Đặt notices sau vào trang Settings của plugin để người dùng luôn nhìn thấy kết quả thao tác.

<?php
add_action('admin_notices', function () {
    if (!is_admin() || !isset($_GET['page']) || $_GET['page'] !== 'mydash') {
        return;
    }
    if (isset($_GET['mydash_updated'])) {
        $id = isset($_GET['edited']) ? sanitize_key(wp_unslash($_GET['edited'])) : '';
        echo '<div class="notice notice-success is-dismissible"><p>' .
            esc_html(sprintf(__('Đã cập nhật widget: %s', 'mydash'), $id ?: '')) .
        '</p></div>';
    }
    if (isset($_GET['mydash_err'])) {
        $err = sanitize_key(wp_unslash($_GET['mydash_err']));
        $msg = '';
        if ($err === 'conflict') {
            $msg = __('Có thay đổi khác vừa được lưu. Vui lòng tải lại trang để hợp nhất trước khi tiếp tục.', 'mydash');
        } elseif ($err === 'invalid_id') {
            $msg = __('ID không hợp lệ.', 'mydash');
        } elseif ($err === 'not_found') {
            $msg = __('Không tìm thấy widget cần sửa.', 'mydash');
        } elseif ($err === 'unsupported_type') {
            $msg = __('Loại widget không được hỗ trợ.', 'mydash');
        } else {
            $msg = __('Có lỗi xảy ra. Vui lòng thử lại.', 'mydash');
        }
        echo '<div class="notice notice-error is-dismissible"><p>' . esc_html($msg) . '</p></div>';
    }
});

Gợi ý thực hành an toàn

  • Không nhận tên callback từ form hay DB. Luôn ánh xạ type hợp lệ sang callback cố định trong plugin qua registry, ví dụ mydashwidgettype_registry. Nếu cần cho phép đổi type, chỉ hiển thị dropdown từ registry và bắt buộc sanitize về một giá trị có sẵn, đồng thời migrate dữ liệu data phù hợp. Mặc định, mình khuyên giữ type cố định khi sửa để giảm rủi ro.
  • Khi lưu, luôn kiểm tra rev. Nếu người khác đã lưu trước một nhịp, đừng overwrite. Hãy báo conflict và yêu cầu tải lại, như handler ở trên.
  • context và priority là ý định cấu hình. Việc đặt vị trí thật được thực hiện ở hook wpdashboardsetup bằng thao tác $wpmetaboxes mà bạn đã cài ở phần nạp widget. Form sửa chỉ thay đổi ý định này trong option.

Xóa widget tùy chỉnh: xác nhận, dọn dẹp và rollback

Khi cần xóa một widget khỏi Dashboard, mình triển khai quy trình an toàn gồm xác nhận có nonce, kiểm tra capability, xóa bản ghi khỏi option cấu hình, tăng rev để khóa lạc quan, dọn cache tạm liên quan, ghi log ngắn gọn, và cho phép Undo trong thời gian ngắn bằng snapshot tạm. Sau khi xóa, widget sẽ không còn được đăng ký ở lần nạp Dashboard tiếp theo vì danh sách đăng ký đọc từ option đã bị gỡ.

Các bước thực hiện

  1. Hiển thị form xác nhận xóa ở trang cấu hình plugin, gửi về admin-post.php với nonce và rev hiện tại.
  2. Handler xóa: kiểm tra capability, nonce, đối chiếu rev. Nếu hợp lệ thì gỡ widget khỏi mảng widgets, tăng rev rồi update_option. Xóa transient cache của widget nếu có. Lưu snapshot tạm để Undo. Ghi một log tối thiểu.
  3. Hiển thị thông báo có nút Undo. Nếu người dùng nhấn Undo trong thời gian hiệu lực, khôi phục snapshot và tăng rev một lần nữa.
  4. Người dùng tải lại trang Dashboard, widget đã bị gỡ sẽ không còn được đăng ký.

Ví dụ hoàn chỉnh: xác nhận xóa, handler xóa và Undo Đoạn code dưới đây hoạt động ngay trong trang Settings của plugin. ID thử nghiệm là mydashhtmlnote, option cấu hình là mydashdashboardwidgets, prefix transient cache là mydashw.

<?php
// 1) In form xác nhận xóa cho một widget
function mydash_render_delete_widget_confirm(string $id): void {
    $cfg = get_option('mydash_dashboard_widgets', []);
    $rev = intval($cfg['rev'] ?? 0);
    $w   = isset($cfg['widgets'][$id]) && is_array($cfg['widgets'][$id]) ? $cfg['widgets'][$id] : null;

    if (!$w) {
        echo '<div class="notice notice-error"><p>' . esc_html__('Không tìm thấy widget để xóa.', 'mydash') . '</p></div>';
        return;
    }

    $title = (string) ($w['title'] ?? $id);
    ?>
    <div class="notice notice-warning">
        <p><?php echo esc_html(sprintf(__('Bạn sắp xóa widget "%s". Hành động này sẽ gỡ widget khỏi Dashboard ở lần tải tiếp theo.', 'mydash'), $title)); ?></p>
    </div>
    <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
        <input type="hidden" name="action" value="mydash_delete_widget">
        <input type="hidden" name="id" value="<?php echo esc_attr($id); ?>">
        <input type="hidden" name="rev" value="<?php echo esc_attr($rev); ?>">
        <?php wp_nonce_field('mydash_delete_widget', 'mydash_nonce'); ?>
        <p>
            <button type="submit" class="button button-secondary"><?php esc_html_e('Xóa vĩnh viễn', 'mydash'); ?></button>
            <a class="button" href="<?php echo esc_url(admin_url('options-general.php?page=mydash')); ?>">
                <?php esc_html_e('Hủy', 'mydash'); ?>
            </a>
        </p>
    </form>
    <?php
}

// 2) Handler xóa
add_action('admin_post_mydash_delete_widget', 'mydash_handle_delete_widget');
function mydash_handle_delete_widget(): void {
    if (!current_user_can('manage_options')) {
        wp_die(esc_html__('Unauthorized', 'mydash'), 403);
    }
    check_admin_referer('mydash_delete_widget', 'mydash_nonce');

    $id  = isset($_POST['id']) ? sanitize_key(wp_unslash($_POST['id'])) : '';
    $rev = isset($_POST['rev']) ? intval($_POST['rev']) : -1;

    if (!$id) {
        wp_safe_redirect(add_query_arg('mydash_err', 'invalid_id', admin_url('options-general.php?page=mydash')));
        exit;
    }

    $cfg = get_option('mydash_dashboard_widgets', []);
    $cur = intval($cfg['rev'] ?? 0);

    if (!isset($cfg['widgets'][$id])) {
        wp_safe_redirect(add_query_arg(['mydash_err' => 'not_found', 'id' => $id], admin_url('options-general.php?page=mydash')));
        exit;
    }

    if ($rev !== $cur) {
        wp_safe_redirect(add_query_arg(['mydash_err' => 'conflict', 'id' => $id], admin_url('options-general.php?page=mydash')));
        exit;
    }

    // Lưu snapshot tạm để Undo trong 15 phút
    $snap_key = 'mydash_undo_' . $id;
    set_transient($snap_key, [
        'id'     => $id,
        'widget' => $cfg['widgets'][$id],
        'rev'    => $cur,
        'at'     => gmdate('c'),
        'by'     => get_current_user_id(),
    ], 15 * MINUTE_IN_SECONDS);

    // Gỡ widget khỏi cấu hình và tăng rev
    unset($cfg['widgets'][$id]);
    $cfg['rev'] = $cur + 1;
    update_option('mydash_dashboard_widgets', $cfg, false);

    // Dọn transient cache liên quan nếu plugin có dùng
    delete_transient('mydash_w_' . $id);

    // Ghi log tối thiểu - giữ tối đa 20 mục
    $log = get_option('mydash_dashboard_widgets_log', []);
    if (!is_array($log)) { $log = []; }
    $log[] = ['t' => gmdate('c'), 'act' => 'delete', 'id' => $id, 'by' => get_current_user_id()];
    if (count($log) > 20) { $log = array_slice($log, -20); }
    update_option('mydash_dashboard_widgets_log', $log, false);

    // PRG
    wp_safe_redirect(add_query_arg(['mydash_deleted' => 1, 'id' => $id], admin_url('options-general.php?page=mydash')));
    exit;
}

// 3) Handler Undo
add_action('admin_post_mydash_undo_delete_widget', 'mydash_handle_undo_delete_widget');
function mydash_handle_undo_delete_widget(): void {
    if (!current_user_can('manage_options')) {
        wp_die(esc_html__('Unauthorized', 'mydash'), 403);
    }
    check_admin_referer('mydash_undo_delete_widget', 'mydash_nonce');

    $id = isset($_POST['id']) ? sanitize_key(wp_unslash($_POST['id'])) : '';
    if (!$id) {
        wp_safe_redirect(add_query_arg('mydash_err', 'invalid_id', admin_url('options-general.php?page=mydash')));
        exit;
    }

    $snap_key = 'mydash_undo_' . $id;
    $snap = get_transient($snap_key);
    if (!$snap || !is_array($snap) || empty($snap['widget'])) {
        wp_safe_redirect(add_query_arg(['mydash_err' => 'undo_expired', 'id' => $id], admin_url('options-general.php?page=mydash')));
        exit;
    }

    $cfg = get_option('mydash_dashboard_widgets', []);
    $cur = intval($cfg['rev'] ?? 0);

    // Không ghi đè nếu ai đó đã tạo lại cùng ID
    if (!isset($cfg['widgets'][$id])) {
        $cfg['widgets'][$id] = $snap['widget'];
        $cfg['widgets'][$id]['updated_at'] = gmdate('c');
        $cfg['rev'] = $cur + 1;
        update_option('mydash_dashboard_widgets', $cfg, false);
    }

    delete_transient($snap_key);

    // Log Undo
    $log = get_option('mydash_dashboard_widgets_log', []);
    if (!is_array($log)) { $log = []; }
    $log[] = ['t' => gmdate('c'), 'act' => 'undo_delete', 'id' => $id, 'by' => get_current_user_id()];
    if (count($log) > 20) { $log = array_slice($log, -20); }
    update_option('mydash_dashboard_widgets_log', $log, false);

    wp_safe_redirect(add_query_arg(['mydash_restored' => 1, 'id' => $id], admin_url('options-general.php?page=mydash')));
    exit;
}

// 4) Thông báo sau xóa và nút Undo
add_action('admin_notices', function () {
    if (!is_admin() || !isset($_GET['page']) || $_GET['page'] !== 'mydash') {
        return;
    }

    if (isset($_GET['mydash_deleted'], $_GET['id'])) {
        $id = sanitize_key(wp_unslash($_GET['id']));
        $snap_key = 'mydash_undo_' . $id;
        $has_snap = (bool) get_transient($snap_key);

        echo '<div class="notice notice-success"><p>';
        echo esc_html(sprintf(__('Đã xóa widget: %s', 'mydash'), $id));
        echo '</p>';

        if ($has_snap) {
            ?>
            <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline-block;margin:8px 0;">
                <input type="hidden" name="action" value="mydash_undo_delete_widget">
                <input type="hidden" name="id" value="<?php echo esc_attr($id); ?>">
                <?php wp_nonce_field('mydash_undo_delete_widget', 'mydash_nonce'); ?>
                <button type="submit" class="button"><?php esc_html_e('Hoàn tác', 'mydash'); ?></button>
            </form>
            <?php
        }

        echo '</div>';
    }

    if (isset($_GET['mydash_restored'], $_GET['id'])) {
        $id = sanitize_key(wp_unslash($_GET['id']));
        echo '<div class="notice notice-success is-dismissible"><p>' .
            esc_html(sprintf(__('Đã phục hồi widget: %s', 'mydash'), $id)) .
        '</p></div>';
    }

    if (isset($_GET['mydash_err'])) {
        $err = sanitize_key(wp_unslash($_GET['mydash_err']));
        $msg = $err === 'conflict'
            ? __('Dữ liệu vừa được thay đổi ở nơi khác. Vui lòng tải lại và thử lại.', 'mydash')
            : ($err === 'not_found' ? __('Không tìm thấy widget.', 'mydash')
            : ($err === 'invalid_id' ? __('ID không hợp lệ.', 'mydash')
            : ($err === 'undo_expired' ? __('Không thể hoàn tác vì snapshot đã hết hạn.', 'mydash')
            : __('Có lỗi xảy ra.', 'mydash'))));
        echo '<div class="notice notice-error is-dismissible"><p>' . esc_html($msg) . '</p></div>';
    }
});

Biến thể dùng biến tên option và prefix cache Nếu bạn đặt tên khác cho option và prefix transient, chuẩn hóa sớm ở đầu plugin rồi dùng nhất quán.

<?php
$mydash_option_name   = 'mydash_dashboard_widgets';
$mydash_cache_prefix  = 'mydash_w_';
$mydash_undo_prefix   = 'mydash_undo_';

// Ví dụ xóa cache theo prefix
function mydash_purge_widget_cache(string $id): void {
    global $mydash_cache_prefix;
    delete_transient($mydash_cache_prefix . $id);
}

Gợi ý thực hành

  • Luôn đối chiếu rev trước khi xóa để tránh ghi đè thay đổi đồng thời. Nếu lệch rev, báo conflict và yêu cầu tải lại.
  • Nếu bạn đang dùng multisite và cache theo phạm vi mạng, thay deletetransient bằng deletesite_transient cho các cache đặt ở cấp network.
  • Undo là tiện ích cho quản trị nội bộ. Đặt TTL ngắn để giảm rủi ro giữ snapshot quá lâu.

Ẩn hoặc vô hiệu hóa widget mặc định, thứ tự hook và chiến lược gỡ nguồn đăng ký

Bạn có hai hướng can thiệp khi muốn loại bỏ widget trên Dashboard: ẩn khỏi UI bằng removemetabox, hoặc gỡ tận gốc nơi đăng ký bằng removeaction. removemetabox an toàn, ít xâm lấn nhưng chỉ loại khỏi giao diện, logic khởi tạo của plugin khác vẫn chạy. Gỡ tận gốc bằng removeaction thì sạch và nhẹ hơn, nhất là khi bên thứ ba làm việc nặng trước khi add widget, nhưng bạn phải biết chính xác callback và priority. Trường hợp đặc biệt như Welcome panel không phải meta box, phải removeaction trên hook welcomepanel. Mấu chốt nữa là thứ tự hook: ẩn bằng removemetabox nên chạy rất muộn trong wpdashboardsetup, còn gỡ tận gốc thì chạy rất sớm, thậm chí ngay từ plugins_loaded.

Các bước áp dụng theo thứ tự hoạt động

  1. Ẩn widget core bằng removemetabox ở wpdashboardsetup với priority thật muộn.
  2. Tắt Welcome panel bằng remove_action vì nó không phải meta box.
  3. Nếu cần triệt tiêu widget của plugin khác, removeaction với callback đăng ký của họ ở priority sớm trong chính wpdashboardsetup, hoặc ở pluginsloaded để chặn ngay từ gốc.
  4. Trường hợp hiếm plugin tái đăng ký rất trễ, dọn lượt cuối ở adminhead-index.php bằng cách unset từ global $wpmeta_boxes.

Ví dụ hoàn chỉnh: ẩn widget core, tắt Welcome panel, chặn widget của bên thứ ba, và dọn dẹp cuối Đoạn sau chạy độc lập trong file plugin của bạn.

<?php
// 1) Chặn nguồn đăng ký của bên thứ ba thật sớm nếu biết callback
add_action('plugins_loaded', function () {
    // Ví dụ plugin Vendor thêm widget ở wp_dashboard_setup priority 10
    remove_action('wp_dashboard_setup', 'vendor_register_dashboard_widget', 10);

    // Nếu họ dùng class static hoặc tên có namespace
    remove_action('wp_dashboard_setup', array('Vendor_Plugin', 'register_dashboard_widget'), 20);
    // remove_action('wp_dashboard_setup', array('\Vendor\Plugin', 'register_dashboard_widget'), 20);
});

// 2) Chặn lại lần nữa ngay trong wp_dashboard_setup ở priority sớm để đảm bảo
add_action('wp_dashboard_setup', function () {
    remove_action('wp_dashboard_setup', 'vendor_register_dashboard_widget', 10);
    remove_action('wp_dashboard_setup', array('Vendor_Plugin', 'register_dashboard_widget'), 20);
}, 1);

// 3) Ẩn widget core và tắt Welcome panel thật muộn
add_action('wp_dashboard_setup', function () {
    // Danh sách widget core thường gặp
    $core = array(
        'dashboard_activity',
        'dashboard_primary',
        'dashboard_quick_press', // ID vẫn là quick_press dù UI ghi Quick Draft
        // Thêm nếu muốn: 'dashboard_site_health', 'dashboard_right_now' trên bản cũ
    );

    foreach ($core as $id) {
        // Khi không chắc context, gọi cho cả normal và side
        remove_meta_box($id, 'dashboard', 'normal');
        remove_meta_box($id, 'dashboard', 'side');
    }

    // Welcome panel không phải meta box
    remove_action('welcome_panel', 'wp_welcome_panel');

    // Tùy chọn: cũng tắt cờ hiển thị đã lưu của người dùng hiện tại
    if (is_user_logged_in()) {
        update_user_meta(get_current_user_id(), 'show_welcome_panel', 0);
    }
}, PHP_INT_MAX);

// 4) Dọn dẹp cuối trong tình huống hiếm khi plugin tái đăng ký rất trễ
add_action('admin_head-index.php', function () {
    // Chỉ chạy trên trang Dashboard chính
    if (!function_exists('get_current_screen')) {
        return;
    }
    $screen = get_current_screen();
    if (!$screen || $screen->id !== 'dashboard') {
        return;
    }

    $force_unset = array(
        'dashboard_activity',
        'dashboard_primary',
        'dashboard_quick_press',
        // Thêm ID widget của plugin khác nếu chúng cứ tái đăng ký
        // 'some_vendor_widget_id',
    );

    mydash_force_unset_dashboard_boxes($force_unset, 'dashboard');
}, PHP_INT_MAX);

/**
 * Hàm tiện ích: unset box khỏi $wp_meta_boxes theo ID, cho các context phổ biến.
 */
function mydash_force_unset_dashboard_boxes(array $ids, string $screen_id = 'dashboard'): void
{
    global $wp_meta_boxes;

    if (empty($wp_meta_boxes[$screen_id])) {
        return;
    }

    $contexts   = array('normal', 'side');
    $priorities = array('high', 'core', 'default', 'low');

    foreach ($ids as $id) {
        $id = sanitize_key($id);
        foreach ($contexts as $ctx) {
            if (empty($wp_meta_boxes[$screen_id][$ctx])) {
                continue;
            }
            foreach ($priorities as $prio) {
                if (isset($wp_meta_boxes[$screen_id][$ctx][$prio][$id])) {
                    unset($wp_meta_boxes[$screen_id][$ctx][$prio][$id]);
                }
            }
        }
    }
}

Biến thể với danh sách cấu hình riêng Bạn có thể trích danh sách cần ẩn từ option của plugin rồi dùng chung cho cả removemetabox và bước unset cuối.

<?php
add_action('wp_dashboard_setup', function () {
    $cfg = get_option('mydash_dashboard_widgets', array());
    $hide = isset($cfg['hidden_core']) && is_array($cfg['hidden_core']) ? array_keys(array_filter($cfg['hidden_core'])) : array();

    foreach ($hide as $id) {
        remove_meta_box($id, 'dashboard', 'normal');
        remove_meta_box($id, 'dashboard', 'side');
    }
    remove_action('welcome_panel', 'wp_welcome_panel');
}, PHP_INT_MAX);

add_action('admin_head-index.php', function () {
    $cfg = get_option('mydash_dashboard_widgets', array());
    $hide = isset($cfg['hidden_core']) && is_array($cfg['hidden_core']) ? array_keys(array_filter($cfg['hidden_core'])) : array();
    mydash_force_unset_dashboard_boxes($hide, 'dashboard');
}, PHP_INT_MAX);

Giải thích quan trọng để bạn nắm vững

  • Vì sao phải chạy removemetabox rất muộn. Hầu hết widget core và plugin khác được đăng ký ở wpdashboardsetup priority mặc định 10. Chạy ở 100 hoặc PHPINTMAX giúp bạn gỡ sau khi mọi thứ đã có mặt trong $wpmetaboxes.
  • Khi nào nên gỡ tận gốc bằng removeaction. Nếu widget không phải meta box như Welcome panel, nếu ID thay đổi động khó đoán, hoặc nếu callback của bên thứ ba thực thi nặng trước khi gọi wpadddashboardwidget thì removemetabox không giúp gì về hiệu năng. Lúc này removeaction ở wpdashboardsetup priority 1 hoặc ở pluginsloaded là cách đúng.
  • Về cơ chế WPHook. Danh sách callback được duyệt theo priority. Nếu bạn removeaction một callback có priority lớn hơn trong khi hook đang chạy, nó sẽ không còn được gọi ở bước đến lượt nó. Vì vậy removeaction ở wpdashboard_setup priority 1 đủ để chặn callback ở priority 10 của plugin khác.
  • Dọn dẹp cuối trong adminhead-index.php để đề phòng plugin cố tình tái đăng ký muộn. Đây là biện pháp an toàn cuối cùng. Bạn nên chỉ dùng khi đã thử removeaction và removemetabox mà vẫn không triệt để.

Mẹo thực hành để xác định đúng ID hoặc callback

  • Mở Dashboard, bật WPDEBUGLOG. Tạm thời vardump(arraykeys($wpmetaboxes[‘dashboard’][‘normal’][‘core’])) trong môi trường dev để xem ID chính xác rồi cập nhật danh sách. Xóa debug ngay sau khi xác định xong.
  • Nếu bạn không biết callback mà plugin dùng để đăng ký widget, tìm trong mã của họ chuỗi wpadddashboardwidget rồi lần ngược hàm bao ngoài. Sau đó removeaction chính hàm đó trên wpdashboardsetup với đúng priority mà họ add_action. Nếu chưa chắc priority, gỡ ở nhiều priority thường gặp như 10, 20.

Cách thường làm là chỉ removemetabox và nghĩ đã xong. Cách đúng là kết hợp đúng thứ tự hook: gỡ nguồn đăng ký thật sớm khi có thể, ẩn bằng removemetabox thật muộn để bảo đảm, và chỉ dùng unset $wpmetaboxes như phương án cuối cùng. Cách này vừa nhẹ, vừa chắc, và không gây tác dụng phụ khó đoán.

Điều khiển cột và ưu tiên hiển thị bằng $wpmetaboxes, tôn trọng order của người dùng

Sau khi wpadddashboardwidget chạy, widget mặc định rơi vào cột normal với priority core. Để đặt nó sang cột mong muốn và chọn priority hiển thị, cách ổn định là thao tác trực tiếp trên global $wpmetaboxes theo 4 tầng key: $wpmeta_boxes[‘dashboard’][context][priority][id]. Mục tiêu là di chuyển bản ghi box khỏi bucket nguồn sang bucket đích và cập nhật thuộc tính context, priority trong chính box. Quan trọng: không ghi vào bucket sorted vì đó là vùng do user meta điều khiển, đụng vào sẽ phá thứ tự mà người dùng đã sắp.

Các bước thực hiện chuẩn

  1. Đăng ký widget như bình thường bằng wpadddashboard_widget.
  2. Ngay sau đó, ở cùng hook wpdashboardsetup nhưng chạy muộn, lấy box theo id từ bucket nguồn và chuyển sang context – priority đích.
  3. Mặc định đặt side – low để tạo trải nghiệm gọn cho người chưa từng tùy biến. Nếu người dùng đã kéo thả và lưu order, core sẽ ưu tiên theo meta-box-order_dashboard và bỏ qua mặc định này – đây là hành vi cần tôn trọng.
  4. Chỉ khi thật sự phải ép vị trí mọi lúc, hãy loại id của widget khỏi user option meta-box-orderdashboard bằng filter getuseroptionmeta-box-order_dashboard, nhưng cân nhắc kỹ UX.

Ví dụ hoàn chỉnh – đăng ký và đặt mặc định side – low, tôn trọng order người dùng

<?php
// Di chuyển sau khi phần lớn widget đã đăng ký
add_action('wp_dashboard_setup', function () {
    $widget_id = 'acme_sales_widget';

    // 1) Đăng ký widget - rơi vào normal-core theo mặc định
    wp_add_dashboard_widget(
        $widget_id,
        'Sales snapshot',
        function () {
            echo '<p>Last 7 days revenue: <strong>$12,340</strong></p>';
        }
    );

    // 2) Di chuyển sang side-low
    global $wp_meta_boxes;

    $page = 'dashboard';
    $dst_context  = 'side';
    $dst_priority = 'low';

    // Tìm ở các khả năng nguồn hợp lý
    $src_contexts          = array('normal', 'side');
    $possible_src_priority = array('core', 'default', 'high', 'low');

    $box = null;
    foreach ($src_contexts as $src_context) {
        foreach ($possible_src_priority as $src_priority) {
            if (isset($wp_meta_boxes[$page][$src_context][$src_priority][$widget_id])) {
                $box = $wp_meta_boxes[$page][$src_context][$src_priority][$widget_id];
                unset($wp_meta_boxes[$page][$src_context][$src_priority][$widget_id]);
                break 2;
            }
        }
    }

    if ($box) {
        // Bảo đảm bucket đích tồn tại
        $wp_meta_boxes[$page][$dst_context] = $wp_meta_boxes[$page][$dst_context] ?? array();
        $wp_meta_boxes[$page][$dst_context][$dst_priority] = $wp_meta_boxes[$page][$dst_context][$dst_priority] ?? array();

        // Cập nhật metadata để core hiểu đúng vị trí
        $box['context']  = $dst_context;
        $box['priority'] = $dst_priority;

        $wp_meta_boxes[$page][$dst_context][$dst_priority][$widget_id] = $box;
    }
}, 99);

Biến thể theo biến cấu hình

<?php
add_action('wp_dashboard_setup', function () {
    $widget_id   = 'acme_news_widget';
    $dst_context = 'normal'; // 'normal' hoặc 'side'
    $dst_prio    = 'high';   // 'high' - 'core' - 'default' - 'low'

    wp_add_dashboard_widget($widget_id, 'Company news', function () {
        echo '<p>Welcome to Q3. All hands meeting at 10:00.</p>';
    });

    global $wp_meta_boxes;
    $page = 'dashboard';

    $box = null;
    foreach (array('normal', 'side') as $src_ctx) {
        foreach (array('core', 'default', 'high', 'low') as $src_prio) {
            if (isset($wp_meta_boxes[$page][$src_ctx][$src_prio][$widget_id])) {
                $box = $wp_meta_boxes[$page][$src_ctx][$src_prio][$widget_id];
                unset($wp_meta_boxes[$page][$src_ctx][$src_prio][$widget_id]);
                break 2;
            }
        }
    }
    if ($box) {
        $wp_meta_boxes[$page][$dst_context] = $wp_meta_boxes[$page][$dst_context] ?? array();
        $wp_meta_boxes[$page][$dst_context][$dst_prio] = $wp_meta_boxes[$page][$dst_context][$dst_prio] ?? array();
        $box['context']  = $dst_context;
        $box['priority'] = $dst_prio;
        $wp_meta_boxes[$page][$dst_context][$dst_prio][$widget_id] = $box;
    }
}, 99);

Vì sao cách trên tôn trọng người dùng

  • Với người chưa có user option meta-box-order_dashboard, core sẽ render theo các bucket priority mà bạn đã sắp – widget của bạn nằm ở side – low.
  • Khi người dùng đã kéo thả, core dùng danh sách trong user option để lấp đầy bucket sorted và lấy đó làm chuẩn. Box bạn vừa đặt ở side – low sẽ bị bỏ qua cho người dùng đó. Điều này đúng mong đợi và tránh việc widget của bạn cứ nhảy vị trí sau khi họ đã sắp xếp.

Ép vị trí mọi lúc – chỉ dùng khi thật sự cần

  • Cách nhẹ và an toàn là lọc user option trước khi core đọc, loại ID widget của bạn khỏi mọi context trong meta-box-orderdashboard. Như vậy core sẽ rơi về mặc định bạn đã set ở $wpmeta_boxes.
<?php
// Cân nhắc UX kỹ trước khi bật
add_filter('get_user_option_meta-box-order_dashboard', function ($value) {
    $force_id = 'acme_sales_widget';

    if (!is_array($value)) {
        return $value;
    }
    foreach ($value as $context => $csv) {
        if (!is_string($csv) || $csv === '') {
            continue;
        }
        $ids = array_filter(array_map('trim', explode(',', $csv)));
        $ids = array_values(array_diff($ids, array($force_id)));
        $value[$context] = implode(',', $ids);
    }
    return $value;
}, 10);

Lưu ý thực hành

  • Không ghi vào key sorted của $wpmetaboxes. Hãy luôn thêm box vào bucket theo context – priority như ví dụ.
  • Nếu cần làm tương tự ở Network Dashboard hoặc User Dashboard, thay page tương ứng thành dashboard-network hoặc dashboard-user và móc vào hook wpnetworkdashboardsetup hoặc wpuserdashboardsetup.
  • Khi debug, bạn có thể vardump các key của $wpmeta_boxes[‘dashboard’] trong môi trường dev để xác định id và bucket thực tế, sau đó tắt ngay đoạn debug.

Bảo mật và phân quyền: capability, nonce, sanitize và escape

Tất cả hành động thêm – sửa – xóa widget và cấu hình liên quan đều phải tuân thủ nguyên tắc ít quyền nhất, chống CSRF bằng nonce, lọc dữ liệu vào, và escape khi xuất. Mình tách thành bốn điểm then chốt để bạn áp dụng ngay vào plugin tùy biến Dashboard.

  1. Chọn capability đúng việc – không lạm dụng manage_options
  • Xem widget
  • Nội dung biên tập: edit_posts.
  • Widget điều phối bình luận: moderate_comments.
  • Widget cấu hình hệ thống: manage_options, nhưng chỉ dùng khi thật sự cần quyền quản trị cấp cao.
  • Tốt nhất: tự định nghĩa capability riêng cho plugin, ví dụ managecustomdashboard, rồi gán cho Administrator. Như vậy bạn dễ phân quyền về sau.
  • Cấu hình plugin và CRUD widget
  • Dùng capability riêng như managecustomdashboard cho trang cấu hình và các handler lưu xóa.
  • Khi đăng ký widget ở wpdashboardsetup, chỉ add khi currentusercan(capability của chính widget). Điều này tránh lộ dữ liệu cho người không đủ quyền.

Ví dụ thêm capability riêng cho Administrator khi kích hoạt plugin:

<?php
register_activation_hook(__FILE__, function () {
    $role = get_role('administrator');
    if ($role && !$role->has_cap('manage_custom_dashboard')) {
        $role->add_cap('manage_custom_dashboard');
    }
});
  1. Chống CSRF bằng nonce – kiểm tra trước khi đụng dữ liệu
  • Trong mọi form thêm – sửa – xóa, in nonce bằng wpnoncefield. Với AJAX, tạo nonce bằng wpcreatenonce và kiểm tra bằng checkajaxreferer.
  • Trên server, gọi checkadminreferer hoặc checkajaxreferer ngay đầu handler. Nếu fail thì dừng xử lý.
  • Với hành động xóa qua URL, dùng wpnonceurl để tạo link an toàn.
  1. Sanitize dữ liệu vào – chọn hàm phù hợp từng trường
  • Text ngắn như tiêu đề, id, slug
  • Title: sanitizetextfield.
  • ID hoặc key: sanitizekey để tránh ký tự lạ. Nếu cần slug hiển thị, có thể dùng sanitizetitle.
  • Nội dung HTML cho phép
  • Dùng wpksespost cho nội dung HTML dạng bài viết. Nếu bạn có whitelist riêng, dùng wpkses với mảng allowedhtml tùy chỉnh.
  • Số và boolean
  • Số nguyên: absint.
  • Boolean: ! empty($POST[‘flag’]) hoặc filtervar($val, FILTERVALIDATEBOOLEAN).
  • Nhớ wpunslash trước khi sanitize vì dữ liệu từ $POST có thể bị addslashes.
  1. Escape dữ liệu ra – luôn escape đúng ngữ cảnh
  • Nội dung văn bản: esc_html.
  • Thuộc tính HTML như value, data-*: esc_attr.
  • Nội dung trong textarea: esc_textarea.
  • HTML đã cho phép: wpksespost.
  • Với URL khi cần: esc_url.

Mẫu triển khai đầy đủ cho một widget có form lưu cấu hình qua admin-post.php

  • Đây là bản điền sẵn hoàn chỉnh, dùng capability riêng managecustomdashboard cho thao tác lưu, và minimal cho người xem là editposts. Dữ liệu nội dung HTML được sanitize bằng wpkses_post khi lưu, và escape đúng ngữ cảnh khi hiển thị.
<?php
// 1) Đăng ký widget nếu người dùng có quyền xem
add_action('wp_dashboard_setup', function () {
    if (! current_user_can('edit_posts')) {
        return;
    }
    $widget_id = 'acme_notes_widget';
    wp_add_dashboard_widget(
        $widget_id,
        'Team notes',
        'acme_notes_widget_render'
    );
});

// 2) Hàm hiển thị widget + form
function acme_notes_widget_render() {
    $cfg = get_option('acme_notes_widget_cfg', array(
        'title'   => 'Notes',
        'content' => '<p>Welcome to the Dashboard.</p>',
        'rev'     => 1,
    ));
    ?>
    <div class="acme-notes-widget">
        <h4><?php echo esc_html($cfg['title'] ?? 'Notes'); ?></h4>
        <div class="inside">
            <?php
            // Nội dung HTML đã được kses khi lưu
            echo wp_kses_post($cfg['content'] ?? '');
            ?>
        </div>

        <?php if ( current_user_can('manage_custom_dashboard') ) : ?>
            <hr>
            <form method="post" action="<?php echo esc_url( admin_url('admin-post.php') ); ?>">
                <input type="hidden" name="action" value="acme_save_notes_widget">
                <?php wp_nonce_field('acme_save_notes_widget', 'acme_nonce'); ?>
                <input type="hidden" name="rev" value="<?php echo esc_attr( (string) ($cfg['rev'] ?? 1) ); ?>">
                <p>
                    <label for="acme_title">Tiêu đề</label>
                    <input id="acme_title" name="title" type="text" class="widefat"
                           value="<?php echo esc_attr($cfg['title'] ?? ''); ?>">
                </p>
                <p>
                    <label for="acme_content">Nội dung HTML</label>
                    <textarea id="acme_content" name="content" class="widefat" rows="6"><?php
                        echo esc_textarea($cfg['content'] ?? '');
                    ?></textarea>
                </p>
                <p>
                    <button type="submit" class="button button-primary">Lưu</button>
                    <a class="button button-link-delete"
                       href="<?php echo esc_url( wp_nonce_url(
                           add_query_arg(array('action' => 'acme_delete_notes_widget'), admin_url('admin-post.php')),
                           'acme_delete_notes_widget',
                           'acme_nonce'
                       ) ); ?>"
                       onclick="return confirm('Xóa nội dung widget?');">
                        Xóa nội dung
                    </a>
                </p>
            </form>
        <?php endif; ?>
    </div>
    <?php
}

// 3) Handler lưu - kiểm nonce trước, rồi kiểm capability, sanitize và lưu
add_action('admin_post_acme_save_notes_widget', function () {
    check_admin_referer('acme_save_notes_widget', 'acme_nonce');

    if ( ! current_user_can('manage_custom_dashboard') ) {
        wp_die( esc_html__('Unauthorized', 'acme'), 403 );
    }

    $stored = get_option('acme_notes_widget_cfg', array('rev' => 1));

    $new = array();
    $new['rev'] = isset($_POST['rev']) ? absint( wp_unslash($_POST['rev']) ) : 0;
    if ($new['rev'] !== (int) ($stored['rev'] ?? 0)) {
        wp_safe_redirect( add_query_arg('acme_conflict', 1, admin_url('index.php')) );
        exit;
    }

    $title   = isset($_POST['title'])   ? sanitize_text_field( wp_unslash($_POST['title']) )   : '';
    $content = isset($_POST['content']) ? wp_kses_post(        wp_unslash($_POST['content']) ) : '';

    $saved = array(
        'title'   => $title,
        'content' => $content, // đã kses an toàn
        'rev'     => (int) ($stored['rev'] ?? 0) + 1,
    );

    update_option('acme_notes_widget_cfg', $saved, false);

    wp_safe_redirect( add_query_arg('acme_updated', 1, admin_url('index.php')) );
    exit;
});

// 4) Handler xóa - nonce qua URL
add_action('admin_post_acme_delete_notes_widget', function () {
    check_admin_referer('acme_delete_notes_widget', 'acme_nonce');

    if ( ! current_user_can('manage_custom_dashboard') ) {
        wp_die( esc_html__('Unauthorized', 'acme'), 403 );
    }

    $stored = get_option('acme_notes_widget_cfg', array('rev' => 1));
    $cleared = array(
        'title'   => 'Notes',
        'content' => '',
        'rev'     => (int) ($stored['rev'] ?? 0) + 1,
    );
    update_option('acme_notes_widget_cfg', $cleared, false);

    wp_safe_redirect( add_query_arg('acme_deleted', 1, admin_url('index.php')) );
    exit;
});

Biến thể lưu kép raw và sanitized để linh hoạt chính sách HTML về sau

  • Dùng khi bạn muốn có khả năng nới whitelist thẻ trong tương lai mà không mất dữ liệu người dùng.
<?php
add_action('admin_post_acme_save_notes_widget_raw', function () {
    check_admin_referer('acme_save_notes_widget_raw', 'acme_nonce');

    if ( ! current_user_can('manage_custom_dashboard') ) {
        wp_die( esc_html__('Unauthorized', 'acme'), 403 );
    }

    $raw_title   = isset($_POST['title'])   ? wp_unslash($_POST['title'])   : '';
    $raw_content = isset($_POST['content']) ? wp_unslash($_POST['content']) : '';

    $san_title   = sanitize_text_field($raw_title);
    $san_content = wp_kses_post($raw_content);

    update_option('acme_notes_widget_cfg_raw', array(
        'title' => $raw_title,
        'content' => $raw_content,
    ), false);

    update_option('acme_notes_widget_cfg', array(
        'title' => $san_title,
        'content' => $san_content,
    ), false);

    wp_safe_redirect( admin_url('index.php') );
    exit;
});

Gợi ý nhanh cho AJAX

  • Trên client gửi mynonce do wpcreatenonce(‘acmeajax’) cung cấp.
  • Server dùng checkajaxreferer(‘acmeajax’, ‘noncefield’), sau đó currentusercan, rồi sanitize và updateoption. Trả về JSON bằng wpsendjsonsuccess với dữ liệu đã được sanitize.

Với bốn lớp bảo vệ trên, bạn tránh được rủi ro lộ dữ liệu và thao tác trái phép: chỉ người có capability tối thiểu cần thiết mới thấy và lưu được, mọi request đều có nonce hợp lệ, dữ liệu vào được lọc đúng mục đích, và dữ liệu ra luôn được escape theo ngữ cảnh.

Giao tiếp dữ liệu widget: admin-post truyền thống và AJAX, chọn kênh phù hợp

Mình chọn kênh theo tiêu chí đơn giản hay tức thời. admin-post phù hợp form submit chuẩn, theo mẫu PRG để tránh double submit và không cần JS. AJAX phù hợp khi bạn muốn cập nhật ngay nội dung widget trên trang và giữ ngữ cảnh người dùng. Hai kênh ngang nhau về bảo mật nếu bạn luôn dùng nonce đúng cách và kiểm tra currentusercan trước khi ghi dữ liệu.

Quy tắc chọn nhanh

  • Cần lưu rồi reload toàn trang kèm thông báo đơn giản: adminpost{action}.
  • Cần cập nhật ngay phần hiển thị mà không reload: wpajax{action}.
  • Dù chọn kênh nào, bắt buộc: nonce hợp lệ, capability phù hợp, sanitize đầu vào và escape đầu ra.

Ví dụ 1 – Submit truyền thống với admin-post và PRG

  • Form đặt ngay trong callback widget. Handler dùng adminpost{action} để lưu rồi redirect về Dashboard.
<?php
// Đăng ký widget
add_action('wp_dashboard_setup', function () {
    if ( ! current_user_can('edit_posts') ) {
        return;
    }
    wp_add_dashboard_widget('acme_trad_widget', 'Team notes - truyền thống', 'acme_trad_widget_render');
});

// Hàm hiển thị widget + form submit truyền thống
function acme_trad_widget_render() {
    $cfg = get_option('acme_trad_widget_cfg', array('title' => 'Notes', 'content' => ''));
    ?>
    <div>
        <h4><?php echo esc_html($cfg['title']); ?></h4>
        <div class="inside"><?php echo wp_kses_post($cfg['content']); ?></div>

        <?php if ( current_user_can('manage_custom_dashboard') ) : ?>
            <hr>
            <form method="post" action="<?php echo esc_url( admin_url('admin-post.php') ); ?>">
                <input type="hidden" name="action" value="acme_trad_save_widget">
                <?php wp_nonce_field('acme_trad_save_widget', 'acme_nonce'); ?>
                <p>
                    <label for="acme_trad_title">Tiêu đề</label>
                    <input id="acme_trad_title" name="title" class="widefat" type="text"
                           value="<?php echo esc_attr($cfg['title']); ?>">
                </p>
                <p>
                    <label for="acme_trad_content">Nội dung HTML</label>
                    <textarea id="acme_trad_content" name="content" rows="5" class="widefat"><?php
                        echo esc_textarea($cfg['content']);
                    ?></textarea>
                </p>
                <p><button type="submit" class="button button-primary">Lưu</button></p>
            </form>
        <?php endif; ?>
    </div>
    <?php
}

// Handler lưu qua admin-post với PRG
add_action('admin_post_acme_trad_save_widget', function () {
    check_admin_referer('acme_trad_save_widget', 'acme_nonce');

    if ( ! current_user_can('manage_custom_dashboard') ) {
        wp_die( esc_html__('Unauthorized', 'acme'), 403 );
    }

    $title   = isset($_POST['title'])   ? sanitize_text_field( wp_unslash($_POST['title']) )   : '';
    $content = isset($_POST['content']) ? wp_kses_post(        wp_unslash($_POST['content']) ) : '';

    update_option('acme_trad_widget_cfg', array(
        'title' => $title,
        'content' => $content,
    ), false);

    wp_safe_redirect( add_query_arg('acme_trad_updated', 1, admin_url('index.php')) );
    exit;
});

Điểm mấu chốt

  • Form action trỏ tới admin-post.php, có hidden input name="action" để map vào hook adminpost{action}.
  • checkadminreferer kiểm nonce trước khi chạm dữ liệu. currentusercan bảo đảm phân quyền. Lưu xong redirect về Dashboard theo mẫu PRG.

Ví dụ 2 – AJAX tối giản cập nhật ngay trên trang

  • Cùng một dạng dữ liệu, nhưng gửi bằng fetch đến admin-ajax.php để cập nhật tức thì. ajaxurl đã sẵn có trong trang admin.
<?php
// Đăng ký widget AJAX
add_action('wp_dashboard_setup', function () {
    if ( ! current_user_can('edit_posts') ) {
        return;
    }
    wp_add_dashboard_widget('acme_ajax_widget', 'Team notes - AJAX', 'acme_ajax_widget_render');
});

// Render widget có nút Lưu qua AJAX
function acme_ajax_widget_render() {
    $cfg   = get_option('acme_ajax_widget_cfg', array('title' => 'Notes', 'content' => ''));
    $nonce = wp_create_nonce('acme_ajax_save_widget');
    ?>
    <div id="acme-ajax-wrap" data-nonce="<?php echo esc_attr($nonce); ?>">
        <h4 id="acme-ajax-title"><?php echo esc_html($cfg['title']); ?></h4>
        <div id="acme-ajax-content" class="inside"><?php echo wp_kses_post($cfg['content']); ?></div>

        <?php if ( current_user_can('manage_custom_dashboard') ) : ?>
            <hr>
            <p><input id="acme_ajax_title_inp" class="widefat" type="text"
                      value="<?php echo esc_attr($cfg['title']); ?>"></p>
            <p><textarea id="acme_ajax_content_inp" class="widefat" rows="5"><?php
                echo esc_textarea($cfg['content']);
            ?></textarea></p>
            <p><button id="acme_ajax_save_btn" class="button button-primary">Lưu bằng AJAX</button></p>

            <script>
            (function(){
                const btn   = document.getElementById('acme_ajax_save_btn');
                const wrap  = document.getElementById('acme-ajax-wrap');
                const title = document.getElementById('acme_ajax_title_inp');
                const cont  = document.getElementById('acme_ajax_content_inp');
                const h4    = document.getElementById('acme-ajax-title');
                const box   = document.getElementById('acme-ajax-content');
                btn.addEventListener('click', function(e){
                    e.preventDefault();
                    const body = new URLSearchParams({
                        action: 'acme_ajax_save_widget',
                        acme_nonce: wrap.getAttribute('data-nonce'),
                        title: title.value,
                        content: cont.value
                    });
                    fetch(ajaxurl, {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
                        body
                    })
                    .then(r => r.json())
                    .then(res => {
                        if (!res || !res.success) throw new Error(res && res.data && res.data.message || 'Lỗi');
                        // Cập nhật ngay UI
                        h4.textContent = res.data.title;
                        box.innerHTML  = res.data.content;
                    })
                    .catch(err => {
                        window.alert('Không lưu được: ' + err.message);
                    });
                });
            })();
            </script>
        <?php endif; ?>
    </div>
    <?php
}

// Handler AJAX
add_action('wp_ajax_acme_ajax_save_widget', function () {
    check_ajax_referer('acme_ajax_save_widget', 'acme_nonce');

    if ( ! current_user_can('manage_custom_dashboard') ) {
        wp_send_json_error(array('message' => __('Unauthorized', 'acme')), 403);
    }

    $title   = isset($_POST['title'])   ? sanitize_text_field( wp_unslash($_POST['title']) )   : '';
    $content = isset($_POST['content']) ? wp_kses_post(        wp_unslash($_POST['content']) ) : '';

    update_option('acme_ajax_widget_cfg', array(
        'title' => $title,
        'content' => $content,
    ), false);

    // Trả về dữ liệu đã sanitize để gán vào DOM an toàn
    wp_send_json_success(array(
        'title'   => $title,
        'content' => $content,
    ));
});

Gợi ý áp dụng

  • Một widget có thể hỗ trợ cả hai kênh. Bạn vẫn dùng cùng một policy bảo mật: nonce hợp lệ trước, rồi capability, sau đó sanitize và lưu. Với nội dung HTML có thể thay đổi whitelist trong tương lai, cân nhắc lưu kép raw và sanitized để có thể re-sanitize khi cần, còn khi render luôn dùng bản đã kses.

Chính sách ẩn – hiện theo người dùng và toàn cục, hợp nhất với user meta

Screen Options cho Dashboard lưu ẩn hiện từng widget theo từng người dùng trong user meta metaboxhiddendashboard. Để có chính sách toàn cục dễ bảo trì, mình tách rõ ba lớp và hợp nhất theo thứ tự ưu tiên: forcedhide thắng hết, forcedshow thắng tùy chọn người dùng còn lại, rồi tới userhidden. Nhờ vậy bạn vừa tôn trọng lựa chọn cá nhân, vừa giữ được ràng buộc bắt buộc ở cấp plugin.

Mục tiêu thực thi

  • Đặt mặc định ẩn cho người chưa từng lưu Screen Options.
  • Hợp nhất theo chính sách mỗi khi đọc user meta để render Dashboard.
  • Nếu cần khóa, chặn việc lưu trái chính sách khi người dùng bấm Screen Options.
  • Tránh gây bối rối bằng cách ẩn hoặc disable checkbox tương ứng trong Screen Options bằng CSS hoặc JS nhẹ.

Mẫu triển khai đầy đủ, điền sẵn

  1. Khai báo chính sách và hợp nhất khi đọc
  • Lưu chính sách trong một option autoload=no, ví dụ acmedashpolicy với 2 mảng forcedhide và forcedshow. Áp dụng vào:
  • defaulthiddenmeta_boxes để đặt mặc định cho người chưa có meta.
  • getuseroptionmetaboxhiddendashboard để hợp nhất ở mọi lần đọc.
<?php
// Helpers
function acme_dash_get_policy(): array {
    $opt = get_option('acme_dash_policy', array());
    $forced_hide = isset($opt['forced_hide']) && is_array($opt['forced_hide']) ? $opt['forced_hide'] : array();
    $forced_show = isset($opt['forced_show']) && is_array($opt['forced_show']) ? $opt['forced_show'] : array();

    // Chuẩn hóa ID widget
    $forced_hide = array_values(array_map('sanitize_key', $forced_hide));
    $forced_show = array_values(array_map('sanitize_key', $forced_show));

    // Nếu có trùng, forced_hide ưu tiên - loại khỏi forced_show
    if ($forced_hide && $forced_show) {
        $forced_show = array_values(array_diff($forced_show, $forced_hide));
    }

    return array(
        'forced_hide' => $forced_hide,
        'forced_show' => $forced_show,
    );
}

// Mặc định ẩn cho user chưa có meta
add_filter('default_hidden_meta_boxes', function ($hidden, $screen) {
    if (! $screen || $screen->id !== 'dashboard') {
        return $hidden;
    }
    $hidden = is_array($hidden) ? $hidden : array();
    $p = acme_dash_get_policy();

    // Thêm forced_hide, loại bỏ forced_show
    $hidden = array_unique(array_merge($hidden, $p['forced_hide']));
    if ($p['forced_show']) {
        $hidden = array_diff($hidden, $p['forced_show']);
    }
    // Chuẩn hóa
    return array_values(array_map('sanitize_key', $hidden));
}, 10, 2);

// Hợp nhất theo chính sách khi đọc user meta đã lưu
add_filter('get_user_option_metaboxhidden_dashboard', function ($hidden, $option, $user) {
    $hidden = is_array($hidden) ? $hidden : array();
    $p = acme_dash_get_policy();

    // Ép ẩn thắng hết
    $hidden = array_unique(array_merge($hidden, $p['forced_hide']));
    // Ép hiện - loại khỏi danh sách ẩn
    if ($p['forced_show']) {
        $hidden = array_diff($hidden, $p['forced_show']);
    }

    return array_values(array_map('sanitize_key', $hidden));
}, 10, 3);

Gợi ý giá trị option mẫu để chạy ngay

<?php
// Đặt mặc định chính sách
add_action('admin_init', function () {
    if (get_option('acme_dash_policy', null) === null) {
        add_option('acme_dash_policy', array(
            'forced_hide' => array('dashboard_quick_press', 'dashboard_primary'),
            'forced_show' => array('dashboard_activity', 'my_custom_widget'),
        ), '', 'no');
    }
});
  1. Khóa cập nhật trái chính sách khi lưu
  • Khi người dùng tick Screen Options, WordPress ghi metaboxhiddendashboard vào user meta. Nếu cần khóa cứng, bạn có thể chặn và điều chỉnh giá trị ngay ở thời điểm ghi bằng filter updateusermetadata. Lưu ý quan trọng: không gọi updateuser_meta trong filter này để tránh đệ quy vô hạn. Hãy ghi trực tiếp bằng $wpdb và làm sạch cache user meta.
<?php
add_filter('update_user_metadata', function ($check, $user_id, $meta_key, $meta_value, $prev_value) {
    if ($meta_key !== 'metaboxhidden_dashboard') {
        return $check; // Không đụng meta khác
    }

    // Dữ liệu mới từ request có thể là mảng hoặc không tồn tại
    $val = is_array($meta_value) ? $meta_value : array();
    $val = array_values(array_map('sanitize_key', $val));

    // Áp chính sách
    $p = acme_dash_get_policy();
    // Bắt buộc ẩn
    $val = array_unique(array_merge($val, $p['forced_hide']));
    // Bắt buộc hiện
    if ($p['forced_show']) {
        $val = array_values(array_diff($val, $p['forced_show']));
    }

    // Ghi trực tiếp, tránh gọi update_user_meta để không tái kích hoạt filter này
    global $wpdb;
    $table = $wpdb->usermeta;
    $serialized = maybe_serialize($val);

    // Kiểm tra meta đã tồn tại chưa
    $existing = get_user_meta($user_id, $meta_key, false); // mảng các giá trị
    if ($existing) {
        // Cập nhật giá trị đầu tiên
        $wpdb->update(
            $table,
            array('meta_value' => $serialized),
            array('user_id' => $user_id, 'meta_key' => $meta_key),
            array('%s'),
            array('%d', '%s')
        );
    } else {
        // Thêm mới
        $wpdb->insert(
            $table,
            array(
                'user_id'    => $user_id,
                'meta_key'   => $meta_key,
                'meta_value' => $serialized,
            ),
            array('%d', '%s', '%s')
        );
    }

    // Xóa cache để giá trị mới có hiệu lực ngay trong request
    wp_cache_delete($user_id, 'user_meta');

    return true; // Short-circuit: coi như update đã xong
}, 10, 5);
  • Cách này khiến mọi thao tác trái chính sách không thể ghi xuống DB, đồng thời trải nghiệm người dùng vẫn thấy đúng ngay sau khi reload nhờ hợp nhất ở thời điểm đọc. Nếu bạn chỉ muốn nhẹ nhàng hơn, có thể bỏ khối filter updateusermetadata và chỉ giữ hai filter đọc ở trên. Kết quả hiển thị vẫn tuân chính sách, chỉ khác là user meta có thể chứa giá trị không khớp nhưng luôn bị override lúc đọc.
  1. Ẩn hoặc vô hiệu hóa checkbox trong Screen Options bằng CSS hoặc JS nhẹ
  • Để giảm bối rối, bạn có thể disable các checkbox bị khóa. Trên Dashboard, các checkbox có name="metaboxhiddendashboard[]" và value bằng ID widget. Đoạn dưới chèn vào adminhead-index.php, đọc chính sách và disable các checkbox tương ứng, đồng thời thêm nhãn chú thích.
<?php
add_action('admin_head-index.php', function () {
    $p = acme_dash_get_policy();
    $forced_hide = wp_json_encode($p['forced_hide']);
    $forced_show = wp_json_encode($p['forced_show']);
    ?>
    <style>
    /* Làm mờ label của checkbox bị khóa để gợi ý trực quan */
    .acme-locked label { opacity: 0.6; }
    </style>
    <script>
    (function(){
        const fh = <?php echo $forced_hide; ?> || [];
        const fs = <?php echo $forced_show; ?> || [];
        // Khóa cả 2 nhóm: forced_hide và forced_show
        const locked = Array.from(new Set([].concat(fh, fs)));
        function lockBoxes() {
            locked.forEach(function(id){
                const sel = 'input[name="metaboxhidden_dashboard[]"][value="' + id + '"]';
                document.querySelectorAll(sel).forEach(function(inp){
                    // Với forced_hide, checkbox phải luôn checked
                    if (fh.indexOf(id) !== -1) {
                        inp.checked = true;
                    }
                    // Với forced_show, checkbox phải luôn unchecked
                    if (fs.indexOf(id) !== -1) {
                        inp.checked = false;
                    }
                    inp.disabled = true;
                    const label = inp.closest('label') || inp.parentElement;
                    if (label && !label.classList.contains('acme-locked')) {
                        label.classList.add('acme-locked');
                        label.insertAdjacentHTML('beforeend', ' <em>(bị khóa bởi chính sách)</em>');
                    }
                });
            });
        }
        // Chạy ngay và sau khi người dùng mở Screen Options
        document.addEventListener('DOMContentLoaded', lockBoxes);
        document.addEventListener('click', function(e){
            if (e.target && e.target.id === 'show-settings-link') {
                setTimeout(lockBoxes, 0);
            }
        });
    })();
    </script>
    <?php
});

Lưu ý áp dụng thực tế

  • Nếu bạn thực sự muốn loại bỏ hoàn toàn một widget khỏi Dashboard và cả Screen Options, hãy dùng removemetabox ở wpdashboardsetup. forced_hide chỉ ẩn qua cơ chế user meta, checkbox vẫn tồn tại nếu không ẩn bằng CSS hoặc JS.
  • forcedshow không thể làm xuất hiện một widget đã bị gỡ khỏi hệ thống bằng removemetabox ở một nơi khác. Hãy thống nhất chính sách: hoặc quản bằng removemetabox, hoặc quản bằng metaboxhiddendashboard, đừng trộn mâu thuẫn.
  • Trên multisite, logic metaboxhidden_dashboard áp cho Site Dashboard. Network Dashboard và User Dashboard có screen id khác, bạn cần chính sách song song riêng nếu muốn áp ở đó.

Cách thường làm là chỉ hợp nhất ở thời điểm đọc vì đơn giản, ít xâm lấn và đủ để đảm bảo kết quả hiển thị đúng. Cách đúng khi bạn cần tính tuân thủ chặt là kết hợp thêm lớp khóa khi ghi như ở filter updateusermetadata và chặn thao tác UI để người dùng không nghĩ rằng tùy chọn của họ có hiệu lực. Với hai lớp này, Dashboard luôn hiển thị nhất quán và không có dữ liệu user meta lạc nhịp với chính sách.

Kiểm thử, hiệu năng và tương thích phiên bản

Mình đóng gói quy trình kiểm thử như một ma trận chạy trên staging để bạn bắt được lỗi sớm, giữ widget nhẹ và không phá Dashboard của các plugin khác. Mục tiêu là đo được thời gian render từng widget, xác nhận chính sách forced luôn áp dụng đúng, và đảm bảo tương thích WordPress 6.x trở lên.

Ma trận kiểm thử trên staging

  • Vai trò người dùng
  • Administrator: toàn quyền cấu hình, thấy đủ widget được phép.
  • Editor: chỉ xem widget có capability phù hợp, không thấy trang cấu hình nếu không đủ quyền.
  • Author hoặc Contributor: xác nhận widget đòi quyền cao hơn bị ẩn.
  • Tài khoản mới tinh: kiểm tra mặc định ẩn hiện và vị trí khi chưa có user meta.
  • Tình huống chức năng
  • Thêm – sửa – xóa widget tùy chỉnh: form có nonce, đổi title, đổi capability, đổi context – priority, payload data.
  • Ẩn – hiện widget mặc định qua cấu hình plugin và chính sách forced: forcedhide thắng, forcedshow loại khỏi danh sách ẩn.
  • Kéo thả sắp xếp: thay đổi vị trí, reload lại để xem user meta lưu đúng, forced còn hiệu lực.
  • Đăng ký – gỡ widget qua wpdashboardsetup: ưu tiên gọi removemetabox rất muộn để gỡ widget mặc định hoặc của plugin khác.
  • Tình huống lỗi và bảo mật
  • Submit form thiếu hoặc sai nonce: từ chối và log.
  • Người dùng không đủ capability truy cập trang cấu hình: bị chặn.
  • Cập nhật đồng thời: kiểm tra cơ chế rev khóa lạc quan phát hiện xung đột.
  • Tình huống hệ thống
  • Kích hoạt – vô hiệu hóa plugin phổ biến có widget Dashboard: WooCommerce, Yoast SEO, Jetpack, Wordfence. Kiểm tra không bị widget tái đăng ký chồng sau khi remove.
  • Bật persistent object cache như Redis để xem hành vi transient và xóa cache.

Bật log và công cụ theo dõi

  • Cấu hình trong wp-config.php trên staging để bắt cảnh báo và viết log vào file:
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', false);
define('SCRIPT_DEBUG', true);
define('WP_ENVIRONMENT_TYPE', 'staging');
  • Cài Query Monitor để xem:
  • Tổng thời gian trang admin, số lượng – thời gian truy vấn DB, truy vấn trùng lặp.
  • HTTP API calls phát sinh trong admin, mã trạng thái, thời lượng.
  • Hooks đang chạy và thứ tự gọi cơ bản.

Đo thời gian render từng widget để bắt điểm nghẽn

  • Bọc callback hiển thị bằng một lớp đo microtime và đếm truy vấn trước – sau. Log vào debug.log, chỉ kích hoạt khi WP_DEBUG bật.
<?php
// Đăng ký widget kèm wrapper đo thời gian
add_action('wp_dashboard_setup', function () {
    $id = 'acme_perf_demo';
    wp_add_dashboard_widget($id, 'ACME - Perf Demo', function () use ($id) {
        acme_render_with_timer($id, function () {
            global $wpdb;
            // Ví dụ nội dung có 1 truy vấn nhẹ
            $count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = 'publish'");
            echo '<p>Bài viết đã xuất bản: ' . esc_html($count) . '</p>';
        });
    });
}, 20);

// Wrapper đo thời gian và số truy vấn
function acme_render_with_timer(string $id, callable $real_cb): void {
    if (! defined('WP_DEBUG') || ! WP_DEBUG) {
        $real_cb();
        return;
    }
    global $wpdb;
    $start_time = microtime(true);
    $start_q    = isset($wpdb->num_queries) ? (int) $wpdb->num_queries : 0;

    $real_cb();

    $t      = round((microtime(true) - $start_time) * 1000, 2); // ms
    $q_diff = isset($wpdb->num_queries) ? ((int) $wpdb->num_queries - $start_q) : 0;
    error_log(sprintf('[ACME PERF] widget=%s time=%sms queries=%d', $id, $t, $q_diff));
    do_action('acme_dash_widget_rendered', $id, $t, $q_diff);
}
  • Ngưỡng gợi ý để soi kỹ hơn: thời gian render > 50 ms hoặc số truy vấn > 5 cho một widget đơn. Với widget đụng API ngoài, tổng thời gian không nên vượt 200 ms nếu đã có cache.

Không chạy truy vấn nặng hoặc API chậm trong callback – thêm cache ngắn hạn

  • Với dữ liệu đổi chậm, hãy bọc bằng transient và thoát nhanh khi có cache. Dùng timeout ngắn và hạ cấp tử tế khi API lỗi.
<?php
function acme_render_sales_widget() {
    $cfg = get_option('acme_dash_cfg', array());
    $rev = isset($cfg['rev']) ? (int) $cfg['rev'] : 0;

    $cache_key = 'acme_sales_summary_v' . $rev; // khóa có version để auto-bust khi cấu hình đổi
    $data = get_transient($cache_key);

    if ($data === false) {
        // Gọi API ngoài có timeout thấp, tránh chặn Dashboard
        $resp = wp_remote_get('https://api.example.com/sales?range=7d', array(
            'timeout' => 2,
            'redirection' => 2,
            'user-agent' => 'ACME-Dash/1.0; ' . home_url(),
        ));
        if (is_wp_error($resp) || wp_remote_retrieve_response_code($resp) !== 200) {
            // Hạ cấp: hiển thị dữ liệu cũ hơn nếu còn, hoặc thông báo nhẹ
            echo '<p>Không tải được dữ liệu mới. Thử lại sau vài phút.</p>';
            return;
        }
        $body = wp_remote_retrieve_body($resp);
        $json = json_decode($body, true);
        $data = array(
            'total' => isset($json['total']) ? (int) $json['total'] : 0,
            'updated_at' => gmdate('c'),
        );
        // Cache 5 phút
        set_transient($cache_key, $data, 5 * MINUTE_IN_SECONDS);
    }

    echo '<p>Doanh số 7 ngày: <strong>' . esc_html(number_format_i18n($data['total'])) . '</strong></p>';
    echo '<p><small>Cập nhật: ' . esc_html($data['updated_at']) . '</small></p>';
}
  • Lưu ý khi thay đổi cấu hình có ảnh hưởng nội dung, tăng rev trong option và dùng nó để tạo khóa transient. Cách này tự động bỏ qua cache cũ mà không phải xóa hàng loạt.
  • Khi bạn biết chính xác các khóa cache, hãy xóa khi lưu cấu hình:
<?php
function acme_after_save_settings(int $new_rev, array $known_ids) {
    foreach ($known_ids as $id) {
        delete_transient('acme_' . $id . '_v' . ($new_rev - 1));
    }
}
  • Với persistent object cache, transients nằm trong cache tầng ngoài. Không dùng wildcard xóa. Hoặc dùng rev-salt như trên, hoặc lưu danh mục khóa để xóa có chủ đích.

Checklist tương thích WordPress 6.x trở lên và plugin phổ biến

  • Phiên bản lõi
  • Kiểm tra trên 6.0, 6.4, 6.6. Xác nhận:
  • removemetabox ở wpdashboardsetup chạy ở priority rất muộn vẫn gỡ được widget mặc định và widget của plugin thông dụng.
  • Di chuyển widget qua $wpmetaboxes vẫn đúng khóa context – priority mà không chạm bucket sorted.
  • Screen Options lưu và đọc metaboxhidden_dashboard như kỳ vọng, filter hợp nhất chính sách hoạt động.
  • Plugin có widget Dashboard
  • WooCommerce, Yoast SEO, Jetpack, Wordfence: bật lần lượt để xem:
  • Có widget nào đăng ký rất muộn không. Nếu có, ưu tiên remove ở PHPINTMAX và thêm một lượt dọn ở adminhead-index.php bằng cách unset từ $wpmeta_boxes khi cần.
  • Gỡ hẳn nguồn đăng ký bằng remove_action nếu plugin đó tự thêm lại sau khi bị gỡ khỏi UI.
  • Môi trường cache
  • Bật Redis Object Cache hoặc Memcached: xác nhận transient hoạt động bình thường, đo lại thời gian render khi có cache và khi hết hạn.
  • Bật plugin page cache: thông thường admin không bị cache HTML, nhưng vẫn kiểm thử để chắc chắn không dính JS delayed làm chậm Screen Options.

Kịch bản hiệu năng cần soi và cách sửa

  • Callback chạy truy vấn không có LIMIT hoặc không dùng chỉ mục. Sửa bằng:
  • Tránh SELECT *. Chỉ lấy cột cần thiết.
  • Bắt buộc LIMIT cho các danh sách. Ví dụ activity gần đây chỉ lấy 5 – 10 bản ghi.
  • Đẩy tổng hợp nặng vào WP Cron để tính sẵn, widget chỉ đọc kết quả đã cache.
  • Gọi HTTP bên ngoài không đặt timeout và không có cache. Sửa bằng set timeout 1 – 3 giây và bọc transient 2 – 10 phút tùy mức tươi.
  • Lạm dụng get_option nhiều lần. Sửa bằng đọc option cấu hình một lần và truyền vào các lớp con hoặc dùng biến tĩnh trong request.

Kiểm soát và dọn cache khi thay đổi cấu hình

  • Luôn tăng rev khi lưu option cấu hình. Sinh khóa cache theo rev để auto-bust.
  • Nếu phải xóa cache cũ, lưu danh sách khóa đã phát sinh trong một option phụ autoload=no rồi lặp xóa có chủ đích. Tránh wpcacheflush vì ảnh hưởng toàn hệ thống.

Ghi chú báo cáo cho người dùng nội bộ

  • Tạo một trang trạng thái nhỏ trong trang cấu hình hiển thị:
  • Thời gian render trung bình gần đây cho từng widget nếu bạn gom log bằng hook acmedashwidget_rendered.
  • Số lần cache hit – miss nếu bạn đếm bằng counter trong transient hoặc user meta admin. Điều này giúp khách hàng hiểu widget đã tối ưu và lý do đôi khi số liệu chậm 2 – 5 phút.

Với quy trình trên, bạn có thể chỉ ra widget nào chậm, chứng minh bằng số liệu thời gian – số truy vấn, sửa đúng điểm bằng cache ngắn hạn và cài đặt timeout, đồng thời đảm bảo mọi thay đổi cấu hình không bị đè bởi cache cũ. Staging là nơi bắt xung đột với plugin phổ biến và kiểm tra lại toàn bộ đường đi hook của Dashboard trước khi lên production.

Ghi chú Multisite: Network, User, Site Dashboard và capability khác biệt

Trong môi trường Multisite, Dashboard có 3 khu vực tách biệt và mỗi khu vực có hook riêng để thêm hoặc gỡ widget. Nếu bạn dùng sai hook hoặc kiểm tra nhầm capability theo site ở Network Admin, widget sẽ không hiển thị hoặc gây lỗi quyền. Cách an toàn là đăng ký theo đúng hook của từng khu vực, gắn screen id chuẩn khi removemetabox, và kiểm tra capability tương ứng theo ngữ cảnh.

Bản đồ nhanh để bạn bám vào:

  • Site Dashboard
  • URL: /wp-admin/index.php
  • Hook: wpdashboardsetup
  • Screen id: dashboard
  • Capability: tùy nội dung widget theo site, ví dụ editposts, moderatecomments, manage_options
  • Network Admin Dashboard
  • URL: /wp-admin/network/index.php
  • Hook: wpnetworkdashboard_setup
  • Screen id: dashboard-network
  • Capability: cấp mạng như managenetwork hoặc cụ thể hơn managenetworkusers, managenetworkplugins, managenetwork_sites
  • User Admin Dashboard
  • URL: /wp-admin/user/index.php
  • Hook: wpuserdashboard_setup
  • Screen id: dashboard-user
  • Capability: tối thiểu read hoặc quyền phù hợp với nội dung hiển thị cho người dùng

Ví dụ đầy đủ: đăng ký 1 widget cho mỗi khu vực, kèm capability đúng và minh họa gỡ widget mặc định theo screen id tương ứng.

<?php
/**
 * Plugin Name: ACME Multisite Dash Notes
 * Description: Demo thêm - gỡ widget cho Site, Network, User Dashboard trong Multisite.
 * Version: 1.0.0
 */

// 1) Site Dashboard
add_action('wp_dashboard_setup', function () {
    if ( ! current_user_can('edit_posts') ) {
        return;
    }

    $id = 'acme_site_widget';
    wp_add_dashboard_widget($id, 'ACME - Site Widget', function () {
        echo '<p>Nội dung dành cho Site Dashboard.</p>';
    });

    // Gỡ một widget mặc định ở Site Dashboard nếu muốn
    // Chạy muộn để chắc đã đăng ký xong
}, 20);

add_action('wp_dashboard_setup', function () {
    // Gỡ ở priority rất muộn để thắng phần lớn plugin khác
    remove_meta_box('dashboard_primary', 'dashboard', 'side');
    // Khi không chắc context
    remove_meta_box('dashboard_primary', 'dashboard', 'normal');
}, PHP_INT_MAX);

// 2) Network Admin Dashboard
add_action('wp_network_dashboard_setup', function () {
    // Không dùng manage_options ở đây vì đó là quyền theo site
    if ( ! current_user_can('manage_network') ) {
        return;
    }

    $id = 'acme_network_overview';
    wp_add_dashboard_widget($id, 'ACME - Network Overview', function () {
        echo '<p>Thống kê cấp mạng. Chỉ hiện với Super Admin.</p>';
    });

    // Di chuyển widget sang cột side - low trên Network Admin
    global $wp_meta_boxes;
    $page = 'dashboard-network';
    $src_context = 'normal';
    $dst_context = 'side';
    $dst_priority = 'low';
    $candidates = array('core', 'default', 'high', 'low');

    $box = null;
    foreach ($candidates as $p) {
        if (isset($wp_meta_boxes[$page][$src_context][$p][$id])) {
            $box = $wp_meta_boxes[$page][$src_context][$p][$id];
            unset($wp_meta_boxes[$page][$src_context][$p][$id]);
            break;
        }
    }
    if ($box) {
        $wp_meta_boxes[$page][$dst_context] = $wp_meta_boxes[$page][$dst_context] ?? array();
        $wp_meta_boxes[$page][$dst_context][$dst_priority] = $wp_meta_boxes[$page][$dst_context][$dst_priority] ?? array();
        $box['context'] = $dst_context;
        $box['priority'] = $dst_priority;
        $wp_meta_boxes[$page][$dst_context][$dst_priority][$id] = $box;
    }
}, 20);

add_action('wp_network_dashboard_setup', function () {
    // Ví dụ gỡ một widget nếu biết ID. Gỡ sai ID thì vô hại.
    remove_meta_box('dashboard_primary', 'dashboard-network', 'side');
    remove_meta_box('dashboard_primary', 'dashboard-network', 'normal');
}, PHP_INT_MAX);

// 3) User Admin Dashboard
add_action('wp_user_dashboard_setup', function () {
    // Cho mọi user đăng nhập đều thấy
    if ( ! current_user_can('read') ) {
        return;
    }

    $id = 'acme_user_notes';
    wp_add_dashboard_widget($id, 'ACME - User Notes', function () {
        echo '<p>Gợi ý nhanh dành cho tài khoản người dùng.</p>';
    });

    // Nếu cần gỡ widget nào đó trên User Dashboard theo ID đã biết
}, 20);

add_action('wp_user_dashboard_setup', function () {
    // Gọi cho cả normal và side khi không chắc context
    remove_meta_box('dashboard_primary', 'dashboard-user', 'side');
    remove_meta_box('dashboard_primary', 'dashboard-user', 'normal');
}, PHP_INT_MAX);

Mẹo phân nhánh và phạm vi nạp plugin:

  • Plugin kích hoạt ở mức mạng sẽ được nạp trong cả Network Admin, User Admin và tất cả Site Dashboard. Vì vậy bạn càng nên bám đúng 3 hook chuyên biệt để tránh chạy sai ngữ cảnh.
  • Plugin chỉ kích hoạt theo site thì không chạy trong Network Admin. Nếu bạn cần widget ở Network Admin, hãy network-activate hoặc chuyển thành MU plugin.
  • Dù có thể dùng isnetworkadmin() hoặc isuseradmin() để rẽ nhánh, cách gọn gàng và an toàn hơn là gắn callback vào đúng wpnetworkdashboardsetup, wpuserdashboardsetup và wpdashboardsetup. Khi cần gỡ widget, luôn truyền screen id chuẩn: dashboard cho Site, dashboard-network cho Network, dashboard-user cho User.

Lưu ý về capability theo ngữ cảnh:

  • Network Admin: dùng managenetwork hoặc các capability network cụ thể theo chức năng widget. Tránh manageoptions vì đây là quyền của site con và thường không đại diện đúng cho Super Admin ở màn hình mạng.
  • User Admin: dùng read để bao phủ mọi tài khoản, hoặc cap cao hơn nếu nội dung nhạy cảm. Không nên dựa vào manage_options tại đây.
  • Site Dashboard: dùng cap theo nghiệp vụ thực sự của widget, ví dụ editposts cho nội dung biên tập, moderatecomments cho bình luận, manage_options cho cấu hình cấp cao.

Cuối cùng, nếu bạn thao tác trực tiếp $wpmetaboxes để di chuyển widget, hãy nhớ key cấp 1 là screen id tương ứng: dashboard, dashboard-network, dashboard-user. Khi gỡ bằng removemetabox, gọi cho cả normal và side để chắc chắn loại khỏi UI trong mọi trường hợp. Cách tổ chức này giúp plugin của bạn sạch ngữ cảnh, đúng quyền và không va chạm giữa 3 Dashboard khác nhau của Multisite.

Lỗi thường gặp và cách khắc phục an toàn

Khi tùy biến Dashboard, một lỗi nhỏ ở hook, quyền hay lưu trữ cũng có thể gây hỏng giao diện hoặc lộ dữ liệu. Mình tổng hợp các bẫy phổ biến và cách xử lý an toàn để bạn áp dụng ngay, giữ code sạch và dễ bảo trì.

  • removemetabox chạy quá sớm nên không gỡ được widget của plugin khác
  • Nguyên nhân: đa số widget được đăng ký ở wpdashboardsetup priority 10. Bạn gỡ ở priority mặc định nên chạy trước, không thấy ID để gỡ.
  • Cách khắc phục:
  • Gỡ thật muộn trong wpdashboardsetup, ưu tiên PHPINTMAX. Khi không chắc context, gọi cho cả normal và side.
  • Nếu bên thứ ba cố tình đăng ký muộn hoặc tái đăng ký, chặn từ gốc bằng remove_action chạy thật sớm.
  • Tình huống bất trị, dọn lần cuối ở adminhead-index.php bằng cách unset trong $wpmeta_boxes trước khi render.
// Gỡ muộn và chắc
add_action('wp_dashboard_setup', function () {
    remove_meta_box('dashboard_primary', 'dashboard', 'side');
    remove_meta_box('vendor_widget_id', 'dashboard', 'normal');
    remove_meta_box('vendor_widget_id', 'dashboard', 'side');
}, PHP_INT_MAX);

// Chặn nguồn đăng ký của bên thứ ba
add_action('wp_dashboard_setup', function () {
    remove_action('wp_dashboard_setup', 'vendor_register_widget', 10);
}, 1);

// Phương án cuối cùng ngay trước khi render
add_action('admin_head-index.php', function () {
    global $wp_meta_boxes;
    unset($wp_meta_boxes['dashboard']['side']['core']['vendor_widget_id']);
});
  • Quên kiểm tra capability và nonce ở form cấu hình widget
  • Hệ quả: lộ dữ liệu, ai có link cũng có thể gửi POST hoặc AJAX.
  • Cách khắc phục: kiểm tra quyền sớm, rồi kiểm tra nonce, sau đó mới xử lý dữ liệu. Sanitize khi lưu, escape khi hiển thị.
add_action('admin_post_acme_save_widget', function () {
    if (! current_user_can('manage_options')) {
        wp_die('Unauthorized', 403);
    }
    check_admin_referer('acme_save_widget', 'acme_nonce');

    $title = isset($_POST['title']) ? sanitize_text_field(wp_unslash($_POST['title'])) : '';
    $html  = isset($_POST['html']) ? wp_kses_post(wp_unslash($_POST['html'])) : '';

    update_option('acme_dash_widget', ['title' => $title, 'html' => $html], false);
    wp_safe_redirect(admin_url('index.php?acme_saved=1'));
    exit;
});
  • Lưu option với autoload=yes gây bloat bộ nhớ
  • Nguyên nhân: cấu hình Dashboard chỉ cần khi vào admin, không nên được nạp ở mọi request frontend.
  • Cách khắc phục: dùng một option duy nhất với autoload=no. Nếu lỡ tồn tại với autoload=yes, đổi bằng delete + add trong activation.
register_activation_hook(__FILE__, function () {
    $val = get_option('acme_dash_cfg', null);
    if ($val !== null) {
        delete_option('acme_dash_cfg');
        add_option('acme_dash_cfg', $val, '', 'no');
    } else {
        add_option('acme_dash_cfg', ['schema_version' => 1, 'rev' => 1, 'widgets' => []], '', 'no');
    }
});
  • Lưu thẳng tên hàm callback trong DB rồi calluserfunc
  • Hệ quả: mở đường thực thi tùy ý nếu dữ liệu bị chèn. Ngay cả khi chỉ admin sửa cũng rủi ro dài hạn.
  • Cách khắc phục: chỉ lưu type hoặc slug, ánh xạ sang whitelist callback trong code. Không bao giờ gọi tên hàm lấy từ DB.
$type_map = [
    'note'  => 'acme_widget_note_render',
    'stats' => 'acme_widget_stats_render',
];
$type = $cfg['widgets'][$id]['type'] ?? 'note';
$cb   = $type_map[$type] ?? 'acme_widget_note_render';
wp_add_dashboard_widget($id, $title, $cb);
  • Ghi vào bucket sorted của $wpmetaboxes làm hỏng thứ tự người dùng
  • Nguyên nhân: sorted là vùng do user meta điều khiển. Viết thẳng vào đó phá order đã lưu.
  • Cách khắc phục: di chuyển box vào bucket context priority như side low hoặc normal high. Không chạm sorted.
global $wp_meta_boxes;
$box = $wp_meta_boxes['dashboard']['normal']['core'][$id] ?? null;
if ($box) {
    unset($wp_meta_boxes['dashboard']['normal']['core'][$id]);
    $box['context']  = 'side';
    $box['priority'] = 'low';
    $wp_meta_boxes['dashboard']['side']['low'][$id] = $box;
}
  • Ép vị trí widget bất chấp user meta
  • Hệ quả: người dùng kéo thả nhưng luôn bị bạn ép lại, trải nghiệm tệ.
  • Cách khắc phục: đặt default bằng side low như trên, tôn trọng order người dùng. Chỉ khi thật sự cần cưỡng chế, lọc user meta khi đọc để loại ID khỏi mọi cột, buộc rơi về default mong muốn.
add_filter('get_user_option_meta-box-order_dashboard', function ($order) {
    $id = 'acme_widget';
    $order = is_array($order) ? $order : [];
    foreach (['normal', 'side', 'column3', 'column4'] as $col) {
        if (!empty($order[$col])) {
            $list = array_filter(explode(',', $order[$col]), fn($x) => $x !== $id);
            $order[$col] = implode(',', $list);
        }
    }
    return $order;
});
  • Dùng manage_options ở Network Admin khiến kiểm tra quyền luôn fail
  • Nguyên nhân: manage_options là quyền theo site, không đại diện cho Super Admin ở màn hình mạng.
  • Cách khắc phục: tại Network Admin dùng capability cấp mạng như managenetwork, và gắn vào hook wpnetworkdashboardsetup.
add_action('wp_network_dashboard_setup', function () {
    if (! current_user_can('manage_network')) {
        return;
    }
    wp_add_dashboard_widget('acme_network', 'ACME Network', 'acme_network_cb');
});
  • Ghi đè cấu hình cùng lúc làm mất thay đổi của nhau
  • Nguyên nhân: nhiều admin cùng sửa, update_option ghi đè không kiểm tra.
  • Cách khắc phục: dùng khóa lạc quan bằng trường rev trong option. So sánh rev từ form với rev trong DB trước khi ghi, nếu lệch thì báo xung đột và yêu cầu tải lại rồi hợp nhất thay đổi.

Với các biện pháp trên, bạn vừa gỡ trúng, vừa đăng ký đúng, vừa bảo vệ dữ liệu và trải nghiệm người dùng. Chốt lại: đặt priority hợp lý, kiểm tra quyền và nonce ở mọi lối vào, lưu một option autoload=no có rev, không bao giờ gọi callback từ dữ liệu người dùng, và tuyệt đối tôn trọng lựa chọn đã lưu của người dùng trừ khi chính sách của bạn đòi hỏi cưỡng chế.

Kết luận

Sau khi áp dụng, bạn sẽ có một plugin gọn nhẹ cho phép thêm, sửa, xóa, sắp xếp và ẩn hiện Dashboard Widgets có kiểm soát, lưu cấu hình an toàn bằng Options API và vẫn tôn trọng Screen Options của từng người dùng. Lưu ý quan trọng: đừng lưu tên hàm callback trong DB, hãy ánh xạ từ type đã được whitelist và luôn kiểm tra nonce cùng capability trước khi ghi dữ liệu.