用hasMany+belongsTo建模父子关系最稳妥:Category模型需同时定义children()和parent()关联,parent_id字段必须为unsignedBigInteger,查树用with预加载配合whereNull('parent_id'),封装scopeWithTree作用域复用,插入前校验parent_id存在并加外键约束。

用 hasMany + belongsTo 建模父子关系最稳妥
无限级分类本质是树形结构,Laravel 里不推荐硬写递归查询或手动拼 SQL。直接在模型里定义好一对多(子)和一对一(父)关联,后续所有操作都基于 Eloquent 的关系链展开。
常见错误是只建 hasMany 却漏掉 belongsTo,导致无法向上追溯父节点,或者反过来只建父关联、查子级时反复 N+1。
-
Category模型中加:public function children()→return $this->hasMany(Category::class, 'parent_id'); - 同时必须加:
public function parent()→return $this->belongsTo(Category::class, 'parent_id')->withDefault(); -
parent_id字段类型必须是unsignedBigInteger(或bigInteger并设nullable()),不能用string或uuid—— 否则belongsTo关联会静默失败
查全部树用 with('children') 配合 whereNull('parent_id')
想一次性拿到完整分类树?别手写递归函数,也别用 DB::raw 拼 WITH RECURSIVE(MySQL 8.0+ 才支持,且 Eloquent 不原生兼容)。Eloquent 的嵌套预加载就是为这设计的。
典型坑是:写了 with('children') 却忘了限制根节点,结果查出所有节点再靠 PHP 过滤,白白拖慢响应。
- 正确姿势:
Category::whereNull('parent_id')->with('children.children.children')->get() - 层级深了就控制预加载深度,比如最多三级:
with(['children', 'children.children', 'children.children.children']) - 如果层级不确定又要求完整树,用
loadMissing()分批补全,但得自己控制递归边界,避免爆栈 - 注意:预加载深度不是无限的,PHP 内存和 MySQL 连接数都会受牵连,5 层以上就要警惕
scopeWithTree 封装成作用域更易复用
每次查树都写一长串 with 很烦,而且容易漏层级或写错顺序。封装成本地作用域,既统一逻辑,又避免重复。
有人把整个树构建逻辑塞进一个 getTreeAttribute 访问器里,结果每次访问属性都触发新查询,性能雪崩。
- 在
Category模型里加方法:public function scopeWithTree($query)→return $query->whereNull('parent_id')->with('children.children.children.children'); - 调用:
Category::withTree()->get(),干净利落 - 如果业务需要不同深度,可加参数:
scopeWithTree($query, $depth = 3),用循环动态构建with数组 - 别在作用域里做数据组装(比如生成扁平数组),那是控制器或资源类该干的事
插入新节点时 parent_id 为空或不存在会静默失败
前端传了个空字符串 '' 或字符串 'null' 当 parent_id,后端没校验就塞进数据库,结果新记录的 parent_id 变成 0(整型强制转换),导致它被当成根节点,但其实不属于任何合法分类。
更隐蔽的问题是:父节点已被删除,但外键没设 onDelete('cascade'),新节点仍能插入,只是查出来时 parent 关系是空对象。
- 入库前强制过滤:
data_set($data, 'parent_id', $data['parent_id'] ?: null) - 迁移里加外键约束:
$table->foreign('parent_id')->references('id')->on('categories')->onDelete('cascade'); - 创建时用事务包裹,并在插入后立即
refresh()验证$category->parent是否可访问,不可访问就抛异常 - 别依赖前端传来的
parent_id值,一定要查一遍Category::find($parentId)确认存在
树结构看着简单,但父子引用一旦断掉,修复成本远高于初期加几行校验和约束。










