aimeos/laravel-nestedset
Laravel nested set package for storing and querying hierarchical data as trees. Adds tree support to Eloquent models with fast ancestor, descendant, sibling, and depth queries via left/right indexes, plus simple APIs for moving nodes and rebuilding trees.
Install and wire it up in three steps.
composer require aimeos/laravel-nestedset
Schema::create('categories', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->nestedSet(); // adds _lft, _rgt, parent_id + index
$table->timestamps();
});
If you prefer explicit columns, the macro is equivalent to nullable unsigned _lft, _rgt, parent_id plus an index on (_lft, _rgt) and parent_id.
use Aimeos\Nestedset\NodeTrait;
class Category extends Model
{
use NodeTrait;
}
$root = Category::create(['name' => 'Products']);
$shoes = Category::create(['name' => 'Shoes'])->appendToNode($root)->save();
Category::create(['name' => 'Sneakers'])->appendToNode($shoes)->save();
$root->refresh()->descendants; // flat, ordered by _lft
Category::defaultOrder()->get()->toTree(); // nested with `children` loaded
Where to look first: the NodeTrait source itself (vendor/aimeos/laravel-nestedset/src/NodeTrait.php). It is small and is the authoritative reference for which move/query methods exist in your installed version. Then read the README section on Moving nodes and Retrieving nodes — those two cover 90% of daily use. If you are migrating from lazychaser/laravel-nestedset, the API is essentially identical; only the namespace (Aimeos\Nestedset) changed, so existing tutorials and StackOverflow answers still apply.
Creating and attaching nodes. There is no parent_id assignment in practice — you position a node relative to another:
$node->appendToNode($parent)->save(); // last child
$node->prependToNode($parent)->save(); // first child
$node->insertBeforeNode($sibling)->save();
$node->insertAfterNode($sibling)->save();
$node->makeRoot()->save(); // new root
$existing->saveAsRoot(); // promote an existing subtree
The save() is mandatory — the move methods stage the change, save() performs the _lft/_rgt rewrite.
Reading. Use the relations for one level, the get* helpers for the full set:
$node->children; // direct children
$node->parent; // parent model
$node->ancestors; // root → parent, ordered
$node->descendants; // whole subtree below, ordered by _lft
$node->getAncestorsAndSelf();
$node->getDescendantsAndSelf();
$node->getRoot(); // walk up
Turning a flat result into a tree is the single most-used pattern in views and APIs:
$tree = Category::defaultOrder()->get()->toTree(); // nested, `children` set
$flat = Category::defaultOrder()->get()->toFlatTree(); // flat, each item has `depth`
// Scoped to one subtree:
$tree = $node->descendants()->defaultOrder()->get()->toTree();
toTree() links nodes by parent_id (so the row must actually have it populated) and relies on _lft ordering to preserve sibling order. Always chain defaultOrder() — without it the DB returns rows in arbitrary order and your tree renders scrambled.
Select-list / dropdown pattern. toFlatTree() plus depth is the clean way to render an indented <select>:
foreach (Category::defaultOrder()->get()->toFlatTree() as $c) {
echo str_repeat('— ', $c->depth) . $c->name;
}
Query scopes avoid materialising subtrees in PHP:
Category::whereDescendantOf($node)->get(); // excludes $node itself
Category::whereAncestorOf($node)->get();
Category::whereNotDescendantOf($node)->get();
Drag-and-drop reordering maps 1:1 onto insertBeforeNode / insertAfterNode / appendToNode:
DB::transaction(function () use ($moved, $target, $position) {
match ($position) {
'before' => $moved->insertBeforeNode($target),
'after' => $moved->insertAfterNode($target),
default => $moved->appendToNode($target),
};
$moved->save();
});
Eager loading. with('children') gives you one level; with('descendants') gives you the whole subtree in one query, and you then call toTree() on the collection:
$root = Category::with('descendants')->find($id);
$tree = $root->descendants->toTree();
Multi-tenant / multiple trees. Filter by your own scope column on every query and, if your fork supports it, override getScopeAttributes() to return e.g. ['menu_id'] so _lft/_rgt are computed per scope. Verify this against your installed source before relying on it — behaviour differs between forks. The safe fallback is a manual ->where('menu_id', $id) on reads and separate roots per menu.
Model::insert(), update() on _lft/_rgt, or mass parent_id updates bypass the trait. Any tree repair should be done with the package's rebuild helper (Model::fixTree(), or rebuildNestedSet() in newer forks) — check which exists in your version. Run it in a queued job on large tables._lft/_rgt for every row to the right of the insertion point. On a 10k-node tree, dragging a node can touch thousands of rows. Wrap moves in a transaction, and on large trees queue them rather than doing them inline in a request.lockForUpdate on the scope) rather than fighting deadlocks in production.whereDescendantOf() excludes the node itself. If you want the node too, use getDescendantsAndSelf() or add orWhere on the primary key. This trips people up in permission checks constantly._lft/_rgt go stale immediately after a move. Call $node->refresh() (or re-query) before doing further positional reads on the same instance._lft/_rgt range, so it keeps blocking sibling math and can appear in toTree() if your scope includes trashed rows. Decide explicitly whether deletes should be hard deletes for tree models.delete() removes descendants — confirm the semantics in your installed version before wiring a "delete category" button to it, and consider requeueing children to the parent instead._lft/_rgt and make sure both are indexed together. On very large trees, watch for overflow (4.29B ceiling for unsigned INT) — BIGINT is a cheap insurance policy.toTree() needs the package collection. It lives on Aimeos\Nestedset\Collection. If you build results with DB::table(), a raw query, or ->pluck(), you get a plain Support\Collection and toTree() will not exist.$node->children inside a Blade loop is a query per row. Prefer ->with('children') for shallow menus and ->descendants + toTree() for deep ones.getLftName() / getRgtName() / getParentIdName() hooks. If you override them, your migration must match — using the nestedSet() macro and custom names together is a common mismatch.lazychaser/laravel-nestedset, so the API surface is battle-tested. Pin the version in composer.json and keep a thin repository/service layer around tree mutations so a future swap is cheap.How can I help you explore Laravel packages today?