`add_submenu_page` 的 `$parent_slug` 到底该写 slug 还是 file 路径?我被 WordPress 的"双轨制"绕进死胡同两小时
昨天给插件加二级菜单,本地一切正常,打包到测试环境后菜单直接消失。排查到最后发现是 $parent_slug 的传值问题——WordPress 在这里搞了套"看脸识别"机制,传不对就当你没传。
先上结论,两种写法都能被识别,但底层走的不是同一条路:
// 写法 A:用顶级菜单的 "menu slug"
add_submenu_page(
'my-plugin-top', // ← 这是 add_menu_page 的 $menu_slug
'子页面标题',
'子页面',
'manage_options',
'my-plugin-sub'
);
// 写法 B:用顶级菜单的 "file 路径"(仅限原生菜单)
add_submenu_page(
'edit.php?post_type=book', // ← 自定义文章类型的"伪路径"
'子页面标题',
'子页面',
'manage_options',
'my-plugin-sub'
);
坑在哪?add_menu_page 自己注册的顶级菜单,必须用 返回的 slug 字符串;而 WordPress 内置菜单(比如 edit.php、upload.php)和部分扩展注册的菜单,反而要用 file 路径或带 query string 的 URL。更恶心的是,传错了不会抛错,就是静默不显示,连 _doing_it_wrong 都不触发。
我踩坑的具体场景:插件 A 用 add_menu_page('My Plugin', ...) 注册顶级菜单,返回 slug 是 my-plugin-top。插件 B(扩展模块)想挂个子菜单进去,我图省事直接写了 plugin_dir_path(__FILE__) . 'admin/main.php'——因为看到有些教程里 $parent_slug 写的是文件路径。结果子菜单死活不出来,global $submenu 里也没有。
翻 wp-admin/includes/plugin.php 里 add_submenu_page 的实现,关键逻辑在这里:
// 简化版核心逻辑
global $_registered_pages;
if ( isset( $submenu[$parent_slug] ) ) {
// 走"已注册菜单"分支,直接挂到 $submenu 数组
$submenu[$parent_slug][] = [ $menu_title, $capability, $menu_slug, $page_title ];
} else {
// 走"hookname 推导"分支,靠 $parent_slug 拼出 hook
$hookname = get_plugin_page_hookname( $menu_slug, $parent_slug );
$_registered_pages[$hookname] = true;
}
第一种情况要求 $parent_slug 必须是 $menu 或 $submenu 全局数组里已有的键;第二种情况 WordPress 会把你传的字符串和 $menu_slug 拼接成 action hook。如果两边都对不上,菜单就"魂飞魄散"了——$_registered_pages 里有记录,但 $submenu 里没挂靠,最终渲染时过滤掉了。
我现在养成了个习惯:注册完顶级菜单立刻把 slug 写进常量,子菜单强制用这个常量,绝不手写字符串:
class My_Plugin_Admin {
const TOP_SLUG = 'my-plugin-top'; // 唯一真相源
public function init() {
add_action('admin_menu', [$this, 'register_menus']);
}
public function register_menus() {
add_menu_page(
'My Plugin',
'My Plugin',
'manage_options',
self::TOP_SLUG, // ← 这里
[$this, 'render_top'],
'dashicons-admin-generic',
30
);
add_submenu_page(
self::TOP_SLUG, // ← 子菜单也必须用这个
'设置',
'设置',
'manage_options',
'my-plugin-settings',
[$this, 'render_settings']
);
}
}
还有个权限节点的细节:子菜单的 $capability 如果比顶级菜单宽松,WordPress 不会拦你注册,但用户点进去会 403。比如顶级菜单要 manage_options,子菜单写 edit_posts,有 edit_posts 没 manage_options 的用户能看到菜单入口,进去就是"权限不足"。
我的做法是子菜单能力值默认继承顶级菜单,需要单独控制时显式覆盖,而不是写死字符串:
public function add_submenu($slug, $title, $capability = null) {
$cap = $capability ?? $this->top_capability; // 默认继承
add_submenu_page(self::TOP_SLUG, $title, $title, $cap, $slug, [$this, 'render']);
}
你们有没有被 $parent_slug 的"双轨制"坑过?或者遇到过更隐蔽的菜单注册问题?

