推荐用 buildTree() 手动组装树结构,先查全部节点再 PHP 构建,避免 N+1 查询;需统一 id 与 parent_id 类型、用 groupBy 加速;高频读写场景改用 spatie/laravel-nested-set。

用 with + 自定义访问器实现懒加载树形结构
直接在模型里加递归关系会导致 N+1 查询,尤其当父节点多、层级深时,数据库压力陡增。Laravel 原生不提供「自动构建无限级树」的查询方法,必须手动控制加载时机和层级深度。
推荐做法是:先查出全部节点,再用 PHP 组织成树,而不是靠 Eloquent 关系一层层嵌套查。但若必须支持按需展开(比如只查一级子类),可用带条件的 hasMany 关系配合 with:
- 在模型中定义
children关系时,显式加上whereNull('parent_id')类似过滤逻辑是错的——这会破坏递归结构;正确做法是只定义基础外键关联,如return $this->hasMany(static::class, 'parent_id'); - 调用时用
with(['children' => function ($q) { $q->orderBy('sort'); }])控制子集排序,避免后续 PHP 排序开销 - 切勿在
with里嵌套children.children,Laravel 不支持无限深度预加载,最多写到children.children.children,且极易触发内存溢出
buildTree() 手动组装树结构最稳
把扁平数据转树是 CPU 密集型操作,但可控、无 SQL 风险,适合中小规模数据(比如后台分类管理,通常几百条以内)。关键不是“怎么写递归函数”,而是“怎么避免重复遍历”。
常见错误现象:Array to string conversion 或空数组返回——多半是没处理好 parent_id 字段为 null 或 0 的不一致情况;或节点 ID 是字符串而 parent_id 是整数,类型不匹配导致找不到父节点。
- 统一字段类型:确保数据库中
id和parent_id同为unsignedBigInteger,PHP 中用(int)强转再比较 - 先用
get()拿到集合,再调用自定义方法:$nodes->groupBy('parent_id')能显著提速,避免每轮都filter() - 示例片段:
public function buildTree($nodes, $parentId = null) { return $nodes->get($parentId, collect())->map(function ($node) use ($nodes) { return $node->merge(['children' => $this->buildTree($nodes, $node->id)]); }); }
用 spatie/laravel-nested-set 处理高频读写场景
如果分类要频繁拖拽排序、移动节点,或者列表页需要分页 + 树形展示(如商品类目 API),buildTree() 就撑不住了——每次移动都要重排整棵树。这时候必须换存储模型。
spatie/laravel-nested-set 通过 lft/rgt 字段维护左右值,让单次查询就能拿到某节点下全部子孙,也支持获取路径、兄弟节点等。但它不是“拿来即用”,有明显约束:
- 迁移必须包含
$table->nestedSet();,且模型要继承NestedNode,不能和普通 Eloquent 混用同一张表 -
rebuild()是救命命令,但大数据量下会锁表;上线前务必在测试环境跑一遍全量重建,观察耗时 - 不要试图用它替代前端树组件的展开/折叠逻辑——它的优势在服务端高效裁剪数据,而非控制渲染状态
前端传参和 API 分页的现实妥协
树形结构天然和分页冲突:你没法对“一棵树”做 limit 20。所以绝大多数真实项目里,“树形接口”其实只返回首层,点开某个节点后再请求它的子节点(带 parent_id 参数)。
这时候后端不用递归,就是个普通带条件的 where 查询。但要注意几个易漏点:
- 前端传来的
parent_id可能是字符串"null",PHP 的is_null()判不了,得用!isset($pid) || $pid == 'null' || $pid === null - 如果允许搜索树中任意节点,别用
LIKE全表扫,先用withAncestors()(nested-set)或加冗余字段如path(如"001/005/012")来加速定位 - API 返回字段里,别直接塞
children数组,除非明确知道深度 ≤ 2;更稳妥的是返回has_children: true,由前端决定是否发起下一次请求
树形结构没有银弹。选 buildTree 还是 nested-set,取决于你改得多还是查得多;而几乎所有线上项目,最后都走向了“首层列表 + 懒加载子节点”的混合方案——因为用户根本不会一次性展开二十层分类。











