laravel无限级分类推荐三种方案:中小型树用withtree递归预加载,大型深树用闭包表,省心方案用spatie/nested-set扩展包,各适配不同规模与性能需求。

在Laravel中构建无限级分类树(如商品类目、组织架构、菜单权限)时,直接用Eloquent递归查询会导致N+1问题,页面加载缓慢甚至超时。你需要一种既能保持关系语义、又避免深度嵌套SQL的方案,且结果必须是可遍历的层级数组或带层级属性的对象。
使用withCount+递归关系预加载
适用于节点总数不超过500、层级深度≤6的中小型树结构,兼顾可读性与性能。
第一步:在模型中定义父子关系和子集统计
在 Category.php 中添加以下方法:
public function parent() { return $this->belongsTo(Category::class, 'parent_id'); }
public function children() { return $this->hasMany(Category::class, 'parent_id')->orderBy('sort_order'); }
public function scopeWithTree($query) { return $query->with(['children' => fn($q) => $q->withTree()])->whereNull('parent_id'); }
第二步:控制器中调用并扁平化为带层级的集合
$tree = Category::withTree()->get();
$flattened = $tree->map(fn($node) => $this->buildNodeWithLevel($node, 0))->flatten(1);
注意:buildNodeWithLevel 是自定义递归方法,需在控制器中实现,不能依赖模型静态方法——否则无法传递当前 level 参数。
原生SQL闭包表(Closure Table)方案
适合高频查询、深度≥8、节点数超2000的场景,牺牲写入性能换取极致读取效率。
方法一:建表并填充路径关系
执行迁移命令创建 closure_table:
Schema::create('category_closure', function (Blueprint $table) {
$table->unsignedBigInteger('ancestor');
$table->unsignedBigInteger('descendant');
$table->tinyInteger('depth')->default(0);
$table->primary(['ancestor', 'descendant']);
});
方法二:插入全路径关系(关键一步不可跳过)
每次新增节点后,必须运行递归插入脚本,将该节点及其所有祖先→后代路径写入 closure_table。漏掉任意一条,后续 withDepth() 查询将缺失层级。
方法三:查询指定节点的完整子树
$subtree = DB::table('category_closure')
->join('categories', 'categories.id', '=', 'category_closure.descendant')
->where('category_closure.ancestor', $rootId)
->select('categories.*', 'category_closure.depth as level')
->orderBy('category_closure.depth', 'asc')
->orderBy('categories.sort_order')
->get();
这一步返回的结果已天然按层级和排序字段排好序,无需 PHP 再做 sort 或递归组装。
使用spatie/laravel-nested-set扩展包
这是最省心的方案,自动维护左右值(lft/rgt),单次查询即可取出整棵树,但要求严格遵循其数据写入规范。
第一步:安装并发布迁移
composer require spatie/laravel-nested-set
php artisan vendor:publish --provider="Spatie\NestedSet\NestedSetServiceProvider" --tag=migrations
第二步:修改模型继承并启用监听
class Category extends Model
{
use \Spatie\NestedSet\Node;
protected $table = 'categories';
}
第三步:确保数据库字段存在 lft、rgt、parent_id
迁移中必须包含:
$table->unsignedBigInteger('parent_id')->nullable();
$table->unsignedBigInteger('lft');
$table->unsignedBigInteger('rgt');
【lft 和 rgt 字段缺一不可,且不能设为 NULL】
第四步:重建树结构(仅首次导入历史数据时需要)
Category::fixTree();
第五步:获取带层级的树形集合
$tree = Category::defaultOrder()->get()->toTree();











