用with()嵌套预加载可高效查树,需控制层级、设parent_id为nullable并加索引,模型中children()和parent()均须定义且参数正确,查整棵树必须加whereNull('parent_id')。

直接用 with() 嵌套预加载就能递归查树,但必须控制层级、避免 N+1 和内存爆炸——90% 的项目根本不需要写递归函数或引入第三方包。
parent_id 字段设计必须允许 NULL 且加索引
根节点的 parent_id 必须为 NULL(不能是 0 或空字符串),否则插入失败或关联静默失效。迁移里要这么写:
$table->foreignId('parent_id')->nullable()->constrained()->nullOnDelete();
常见错误包括:
- 设了
NOT NULL约束,导致根分类插不进数据库,报Integrity constraint violation - 字段类型用了
string或uuid,belongsTo关联会匹配失败,查不到父级 - 漏掉索引,深层查询时
WHERE parent_id = ?变成全表扫描,几百条数据就明显卡顿
模型里 children() 和 parent() 都得定义
只定义 children() 不够,向上追溯父级时会出错;只定义 parent() 则无法向下展开子树。两个方法都要写,且注意参数顺序:
public function children()
{
return $this->hasMany(Category::class, 'parent_id');
}
public function parent()
{
return $this->belongsTo(Category::class, 'parent_id')->withDefault();
}
withDefault() 很关键:没有它,访问 $category->parent->name 时根节点会抛 Trying to get property 'name' of non-object。
别把外键名错写成第二个参数位置,比如 hasMany(Category::class, 'id') 就完全反了——这会导致所有子查询返回空。
with() 嵌套写法控制递归深度,别信“无限递归”
Laravel 的 with() 本身不递归,只是支持多层嵌套预加载。想查三级分类树,就写:
Category::whereNull('parent_id')
->with(['children', 'children.children', 'children.children.children'])
->get();
这种写法一次性发 4 条 SQL(根 + 3 层子),比在 Blade 里循环调用 $category->children 触发懒加载强得多。
如果真要动态深度,可用闭包式写法(Laravel 9+):
public function scopeWithChildren($query)
{
return $query->with(['children' => fn ($q) => $q->withChildren()]);
}
但要注意:这个写法在数据量大或层级深(>5 层)时,PHP 内存和 MySQL 连接数都会吃紧,不是“安全无限”,而是“容易爆栈”。
查整棵树时必须限定根节点,别漏 whereNull('parent_id')
写了 with('children.children') 却没加 whereNull('parent_id'),结果查出全部分类再靠 PHP 过滤,白白拖慢响应。
正确姿势是明确从根开始:
Category::whereNull('parent_id')->with('children.children')->get();
如果业务需要排除某分支(比如禁用某个父分类及其所有后代),不要手写递归删,先用一次查询拿到所有要排除的 ID:
$toExclude = Category::where('id', $excludedRootId)
->with('childrenRecursive') // 自定义递归 scope
->get()
->pluck('id')
->toArray();
Category::whereNotIn('id', $toExclude)->get();
真正难的从来不是“怎么递归”,而是判断该查几层、哪些节点该缓存、哪次请求其实只需要一级子类就够了——这些得看接口用途,不是堆代码能解决的。


















