eloquent 的 with() 无法直接加载无限级分类,因其仅支持一级预加载,硬编码多层嵌套既受限又引发 n+1 问题;推荐闭包表模式配合事务维护祖先路径,再用 withdepth() 单次查询带层级扁平结果。

为什么不能直接用 Eloquent 的 with() 加载无限级分类
因为 with() 默认只支持一级预加载,即使写成 with('children.children.children') 也得手动限定层级,且 N 层嵌套会触发 N 次查询(N+1 问题)或生成超长 SQL。更关键的是:真实业务中树深不可控,比如后台可自由拖拽排序、移动节点,硬编码层级等于自埋雷。
用 withDepth() + defaultOrdering() 一步查出带层级的扁平结果
Laravel 9+ 的 staudenmeir/laravel-cte 扩展(配合 PostgreSQL/MySQL 8.0+)能真正实现单次递归查询。但多数项目用的是 MySQL 5.7 或 SQLite,这时推荐「闭包表(Closure Table)」模式 —— 即额外建一张 category_ancestors 表记录所有祖先路径:
CREATE TABLE category_ancestors (
ancestor_id BIGINT UNSIGNED NOT NULL,
descendant_id BIGINT UNSIGNED NOT NULL,
depth TINYINT UNSIGNED NOT NULL,
PRIMARY KEY (ancestor_id, descendant_id),
FOREIGN KEY (ancestor_id) REFERENCES categories(id) ON DELETE CASCADE,
FOREIGN KEY (descendant_id) REFERENCES categories(id) ON DELETE CASCADE
);插入/移动节点时由模型事件自动维护该表。查某分类的所有子树只需:
Category::whereHas('ancestors', fn ($q) => $q->where('ancestor_id', $rootId))
->withDepth()
->orderBy('depth')
->get();- 返回结果是扁平集合,每个模型带
$category->depth属性 - 避免了 PHP 层递归拼装,数据库原生排序更稳
- 注意:
withDepth()是扩展包提供的方法,不是 Laravel 内置
前端渲染时怎么安全判断缩进和展开状态
别在 Blade 里写 @for($i = 0; $i depth; $i++) @endfor —— 这种靠空格缩进既难调试又不利于无障碍访问。正确做法是:
- 后端统一返回带
parent_id和depth的数组,不拼 HTML - 前端用 CSS
padding-left: calc(1rem * var(--depth));控制缩进 - 折叠/展开状态由前端组件(如 Vue 的
v-if)控制,后端只提供has_children字段 - 如果必须服务端渲染,用
<details><summary></summary></details>原生标签,比 JS 切换 class 更轻量
移动节点时最容易漏掉的三个事务点
把分类 A 移动到分类 B 下,看似只是改 parent_id,实际要同步处理:
- 原父节点的子计数(
children_count)要减 1 - 新父节点的子计数要加 1
- 闭包表中 A 的所有祖先路径要清空,并重新插入以 B 为起点的新路径(包括 B 自身)
这三步必须包裹在 DB 事务里,否则出现计数错乱或子树丢失。别信“先删再插”的简单逻辑 —— 如果中间出错,闭包表就残缺了,后续 withDepth() 查询直接失效。










