用 menu 和 menuitem 两个类即可解决菜单父子结构与权限递归校验问题:menu 是容器,menuitem 是叶子节点,仅 menuitem 存 permission_code,校验必须穿透至叶子,构建树时需注意 parent_id 是否为 null 或 0,缓存应按用户+权限码粒度而非整棵树。

组合模式不是为了炫技,而是解决「菜单有父子、权限要递归校验」这个具体问题
直接说结论:用 Menu 和 MenuItem 两个类就够了,Menu 是容器,MenuItem 是叶子节点,别让 MenuItem 拥有 addChild() 方法——它不是容器,加了反而破坏类型语义,后续调用 hasPermission() 时容易误判或抛出异常。
真实业务里,权限粒度至少到按钮级(比如 user:export),而这个码只存在最末级的 MenuItem 的 permission_code 字段里。父级菜单(如「用户管理」)本身不带权限,只起组织作用。所以校验逻辑必须穿透到叶子,不能停在中间层。
-
Menu::add($item)只接受MenuItem实例,拒绝其他类型,靠 PHP 类型声明或运行时instanceof检查兜底 -
Menu::hasPermission($code)内部用 DFS 递归遍历所有子项,只在MenuItem上比对$item->permission_code === $code -
MenuItem不暴露children属性,也不实现add()—— 它的职责就是「代表一个可校验的权限点」
从扁平数据构建成树时,90% 的坑出在 parent_id 判空逻辑上
数据库查出来的权限数据是扁平的:id、name、parent_id。构建树前先确认 parent_id 字段定义:是 TINYINT DEFAULT 0 还是 INT NULL?这直接影响根节点筛选条件。
常见错误现象:buildTree($data, 0) 返回空数组,但数据明明有 parent_id = 0 的项——其实是数据库里存的是 NULL,代码却在比 == 0。
- MySQL 中若
parent_id是INT NULL,筛选根节点得写is_null($item['parent_id']) || $item['parent_id'] == 0 - 若定义为
TINYINT DEFAULT 0,则统一用$item['parent_id'] == 0即可,但要注意插入时别漏设默认值 - 递归函数里不要每次遍历全量数组,先按
parent_id做一次分组索引(如$map[$item['parent_id']][] = $item),能显著减少嵌套循环次数
缓存策略别缓存整棵树,按需查权限码更高效
一次 HTTP 请求通常只校验 1–2 个权限码(比如访问 /admin/user/list,只需确认是否有 user:list),而不是把整个菜单树拉出来再遍历。缓存整棵树看似省事,实则浪费内存、增加序列化开销,且权限变更时失效成本高。
- 推荐缓存键格式:
"user:{$userId}:permissions",值为该用户所有有效permission_code的array或Set结构 - 生成方式:用
Menu::forUser($userId)->getFlatPermissions()预加载,内部仍是 DFS,但只收集叶子节点的permission_code,不构造完整树 - Laravel 用户注意:别在
Gate::define()闭包里实时查数据库树,先预加载进内存再查in_array($code, $permissions)
前端权限指令里的权限码格式必须和后端存储严格一致
Vue 模板里写 v-permission="'order.export'",后端数据库存的却是 order:export 或 order_export,校验永远失败。这不是逻辑问题,是约定断裂。
- 统一用英文点号
.分隔层级(如system.user.create),避免冒号或下划线——冒号在 URL 路由中易被截断,下划线在某些旧框架里有特殊含义 - 所有权限码入库前强制小写 + trim,防止
User.List和user.list被当成两个权限 - 调试时直接
var_dump($menuItem->permission_code)和前端传入的字符串做对比,别猜
最常被忽略的一点:权限码不是菜单名的简单拼接。比如「导出订单」按钮的码是 order.export,但它的父菜单「订单管理」可能根本没配 permission_code,强行给它塞一个只会让校验逻辑变重、语义变乱。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











