用hasmany+belongsto即可实现无限级分类,关键在合理设计parent_id字段、控制查询层级而非盲目递归,90%场景只需预加载2层;扁平化数据+前端建树比嵌套json更稳定高效。

用 hasMany + belongsTo 搭建父子关系就足够了
无限级分类在 laravel 里不需要额外包或复杂递归查询——只要数据库字段设计合理(比如 parent_id),模型间用标准关联就能支撑任意深度。关键不是“怎么递归”,而是“什么时候该查子树、什么时候只查一层”。
常见错误是刚建好 Category 模型就急着写 with('children'),结果 N+1 或爆内存。其实 Laravel 默认不递归加载,with() 只做一层预加载,想查整棵树得手动控制层级。
-
parent_id字段必须允许为NULL(根节点)且加索引 - 模型中定义
children关联时,要用hasMeny(..., 'parent_id'),别错写成foreignKey参数位置不对 - 避免在 Blade 中循环调用
$category->children——这会触发懒加载,每层都发新 sql
查整棵树用 whereDescendantOf()?先装 kalnoy/nestedset 吗
不用。如果你只是偶尔需要获取某个节点的全部后代(比如后台导出分类树),原生 Eloquent 加简单递归函数就够了;但若高频读取、还要拖拽排序、移动子树,那才值得引入 kalnoy/nestedset。它改用左右值模型,读快写慢,且和 Laravel 的软删除、作用域等机制有兼容风险。
真实场景中,90% 的“无限级”需求其实只需要展开 3 层以内(如:频道 → 分类 → 子类),这时候用「预加载 + 控制深度」比换方案更稳。
- 用
with(['children' => fn ($q) => $q->with('children')])最多预加载两层,第三层起不查 -
kalnoy/nestedset要求表结构变更(_lft/_rgt字段),迁移成本高,测试覆盖不到位容易数据错乱 - 它的
getDescendants()返回的是集合,不是查询构造器,无法链式添加where()条件
scopeWithTree() 这种全局作用域真能复用吗
能,但容易误用。自定义作用域适合封装固定逻辑,比如“只取启用状态的完整分类树”,但它不能动态控制递归深度,也不能按需加载关联字段(比如有时要带 products_count,有时不要)。
更灵活的做法是把树形数据组装逻辑抽到服务类或资源类里,而不是堆在模型上。模型只管单层关系,组装交给上层。
- 作用域里写递归调用
$this->children()会导致死循环(Eloquent 会尝试加载自身) - 如果 scope 内用了
with(),调用方再写with()会被覆盖,不是合并 - 测试时难 mock,尤其涉及多层嵌套后,断言容易变成“验证数组嵌套层数是否等于 4”这种脆弱逻辑
前端渲染树时,后端该返回扁平数组还是嵌套数组
返回扁平数组(带 id、parent_id、depth)更可控。Laravel 的 Collection::nest() 方法不稳定(5.x 已移除,8+ 不再内置),自己写递归组装嵌套结构容易栈溢出或漏节点;而前端用 map 一次遍历就能建树,还能自由控制是否折叠、是否显示计数。
后端强行返回嵌套结构,反而增加 JSON 序列化开销,且一旦字段名变化(比如把 name 改成 title),前后端都要改。
- 数据库查出所有相关节点后,用
collect($rows)->groupBy('parent_id')+ 简单 while 循环即可生成带层级的扁平列表 - 避免用
json_encode($collection->toTree())——toTree()是第三方扩展方法,非 Laravel 原生,项目升级可能失效 - 如果接口要支持分页,嵌套结构根本没法分页;扁平结构可配合
limit+ 前端虚拟滚动
真正麻烦的从来不是“怎么写出无限级”,而是“怎么让第 5 层的子分类在搜索、筛选、权限校验时不拖慢整个请求”。树形结构越深,越要警惕查询范围失控——比如一个 whereHas('children.children.children') 看似无害,实际可能扫全表。