Zsens Admin 插件钩子"装死"事件:路由明明通了、控制器也进了,为何 `admin_init` 就是不点火
上周给插件加了个"批量清理过期缓存"的后台功能,路由配完、控制器写好、视图也渲染出来了,点按钮——没反应。不是报错,是彻底静默,像往黑洞里扔石头。折腾两小时才发现,钩子压根没触发。这篇把当时踩的三个连环坑摊开,都是血泪换的。
第一坑:钩子名写错一个下划线,系统当你空气
我注册的是这个:
Hook::add('admin_init', [__CLASS__, 'registerCacheCleaner']);
但实际系统里这个钩子叫 admin.init,点号,不是下划线。Zsens Admin 的钩子命名有两套历史包袱:早期版本用下划线,2.4 之后统一改成点号做事件命名空间,但文档里还留着旧示例。最阴的是,Hook::add 不会校验钩子是否存在,写错了也不抛异常,就当你没写过。
排查办法:在 Hook.php 里临时加一行 error_log('Fired: ' . $hookName),跑一遍后台所有页面,看期望的钩子有没有在日志里露头。没有?名字肯定错了。
第二坑:钩子优先级被"插队",你的回调永远排最后
修正名字后还是不行。打印 Hook::getListeners('admin.init') 一看,我的回调优先级是 10(默认),但前面有个第三方插件挂了优先级 1 的回调,里面直接 exit 了。不是抛异常,是硬退出,后面所有回调被截断。
这种"流氓插队"在装了一堆插件的环境里特别常见。我的修复不是改优先级去抢跑——那只会内卷——而是把核心逻辑拆到 admin.init 之前更早的 app.bootstrap 做预注册,确保至少有个兜底:
// 先挂到更早的钩子做"占座"
Hook::add('app.bootstrap', function () {
// 预加载配置,防止后续被 exit 截断
Config::preload('cache_cleaner');
}, 5);
// 真正的业务逻辑仍放 admin.init,但不再依赖它一定跑完
Hook::add('admin.init', [__CLASS__, 'registerCacheCleaner'], 3); // 抢前一点
第三坑:控制器进了、路由通了,但钩子触发时机在你意料之外
最隐蔽的这个。我以为是"点击按钮 → 表单提交 → 控制器处理 → 触发钩子",实际流程是:按钮走的是 AJAX,AJAX 路由我配在了 api/ 前缀下,而 admin.init 只在传统后台页面生命周期里触发,API 入口压根不加载后台钩子栈。
看路由定义:
// 错的:API 路由不会触发 admin.init
Route::post('api/cache/clean', [CacheController::class, 'clean']);
// 对的:显式声明需要后台上下文,或改用 api.admin.init
Route::post('admin/api/cache/clean', [CacheController::class, 'clean'])
->middleware('admin.context'); // 强制加载后台钩子
或者更干净的做法:API 逻辑不依赖 admin.init,自己在控制器里手动分发需要的事件。钩子不是 religion,该绕就绕。
一个快速诊断清单
现在遇到"钩子不点火",我按这个顺序查,五分钟定位:
1. grep -r "你的钩子名" vendor/zsens/admin/src/Hook/ 确认系统里真实存在这个名字
2. 临时在 Hook::trigger 里打日志,确认触发点有没有被执行到
3. 打印 getListeners 看优先级队列,检查有没有前置回调搞 exit 或 return false
4. 确认当前请求生命周期确实会经过这个钩子(API ≠ 传统页面)
5. 最后才怀疑是不是自己的回调写崩了——其实大部分时候都不是
钩子系统最大的坑不是复杂,是"静默失败"。它不报错,你就得自己造噪音。建议每个插件在开发模式里默认开启 HOOK_DEBUG 常量,把触发日志写进独立文件,生产环境再关掉。这半小时的配置,能救你未来无数个两小时。

