
本文介绍如何在 Laravel 中通过递归遍历 hasManyThrough 关系,高效获取任意深度的页面子级(如 page1 → page2 → page3…),避免硬编码多层 with() 加载,兼顾性能与可维护性。
本文介绍如何在 laravel 中通过递归遍历 `hasmanythrough` 关系,高效获取任意深度的页面子级(如 page1 → page2 → page3…),避免硬编码多层 `with()` 加载,兼顾性能与可维护性。
在构建具有树状结构的 CMS 或多级导航系统时,页面间的父子层级关系(如 1 → 2 → 3 → 4)是常见需求。Laravel 原生不直接支持无限深度的 Eloquent 关系预加载,但可通过合理设计模型关系 + 递归收集策略,实现动态、无深度限制、低耦合的子页面获取。
✅ 正确建模:hasManyThrough 的精准用法
首先确认你的 PageRelation 表结构是否符合 hasManyThrough 的预期:
-
pages表:主表(id,title, 等) -
page_relations表:中间表(id,parent_id,page_id) - 目标:从某页出发,一次性查出所有直系及间接子页(即
parent_id = X→page_id = Y,再以Y为parent_id继续向下)
你定义的 cycles() 关系是正确的:
public function cycles()
{
return $this->hasManyThrough(
self::class, // 最终目标模型(子页面)
PageRelation::class,// 中间关联表
'parent_id', // 中间表外键 → 指向当前页(父页 ID)
'id', // 目标表主键(子页 ID)
'id', // 当前模型主键(父页 ID)
'page_id' // 中间表外键 → 指向子页(即 page_id 字段)
);
}
⚠️ 注意:hasManyThrough 在此场景下仅返回一级子页(即直接子节点),而非全部后代。这是 Laravel 的设计行为,也是我们需递归处理的根本原因。
✅ 优雅递归:避免 N+1 与硬编码 with()
原方案中通过 with(['cycles.cycles.cycles...']) 强行展开 6 层,并手动拼接 *.route 预加载,存在两大缺陷:
- 深度固定,无法应对动态层级;
- 预加载路径爆炸式增长(n 层需 n² 个
with()条目),严重降低可读性与可维护性。
推荐解法:分离「关系查询」与「数据收集」逻辑
利用内存递归(非数据库递归),在模型中定义一个 children 访问器,按需懒加载完整子树:
// App\Models\Pages.php
protected $appends = ['children'];
public function getChildrenAttribute()
{
// 使用 collect() 构建空集合,用于累积所有后代
$allChildren = collect();
// 递归函数:深度优先收集所有后代
$collectAll = function ($page) use (&$collectAll, &$allChildren) {
foreach ($page->cycles as $child) {
$allChildren->push($child);
$collectAll($child); // 继续向下递归
}
};
$collectAll($this);
return $allChildren;
}
✅ 优势:无需预加载深层关系;代码简洁;层级完全动态;配合
with('route')即可统一加载所有子页路由。
✅ 控制器:极简调用,按需加载关联
控制器只需一次查询父页(排除被作为子页的 ID),再逐个触发 children 访问器即可:
public function output_darkside_pages()
{
// 找出所有“顶级页”(未被任何 page_relation 的 page_id 引用的页)
$childPageIds = PageRelation::pluck('page_id')->toArray();
$topPages = Pages::with('route')->whereNotIn('id', $childPageIds)->get();
$result = $topPages->map(function ($page) {
// 触发 children 访问器,自动递归收集全部后代
return (object) [
'page' => $page,
'childs' => $page->children->map->only(['id', 'title'])->values(), // 可选:只取关键字段
];
});
return response()->json($result);
}
⚠️ 性能提示与进阶建议
-
N+1 风险规避:上述递归发生在 PHP 内存中,
$page->cycles已由with('cycles')一次性预加载,因此不会产生额外 SQL 查询。确保始终使用with('cycles')加载根页。 -
深度过大?加保护:若业务允许最大深度(如 20 层),可在递归中加入计数器防止栈溢出:
$collectAll = function ($page, $depth = 0) use (&$collectAll, &$allChildren) { if ($depth > 20) return; foreach ($page->cycles as $child) { $allChildren->push($child); $collectAll($child, $depth + 1); } }; -
需要路由?统一预加载:所有子页的路由可通过
with('route')一次性加载——因为children返回的是Pages实例集合,其route关系已可用:$page->children->load('route'); // 在访问前集中加载(可选)
✅ 总结
| 方案 | 是否动态深度 | 是否易维护 | 是否避免 N+1 | 推荐指数 |
|---|---|---|---|---|
硬编码 with('cycles.cycles...')
|
❌ 固定 | ❌ 极差 | ✅(但冗余) | ⭐☆ |
内存递归 + hasManyThrough + with('cycles')
|
✅ 无限 | ✅ 清晰 | ✅ 安全 | ⭐⭐⭐⭐⭐ |
真正优雅的 Laravel 树形关系处理,不在于“让数据库递归”,而在于用最少的查询拿到足够数据,再用 PHP 智能组织。你的 hasManyThrough 是正确起点,只需辅以轻量递归逻辑,即可彻底告别硬编码层级。











