arcanedev/laravel-nestedset
Laravel Nested Set offers an efficient way to store and query hierarchical data using the nested set model, with a fluent API for creating, moving, and traversing trees and fast ancestor/descendant lookups without recursive queries.
Know what you're installing first. arcanedev/laravel-nestedset is an old (last release Feb 2017) wrapper/fork of the lazychaser/kalnoy nested-set model. The API you'll write against day-to-day is the familiar NodeTrait API: _lft, _rgt, parent_id columns, saveAsRoot(), appendToNode(), getDescendantsAndSelf(), toTree(), and scopes like whereIsRoot(). Because it's a thin wrapper, most upstream Kalnoy documentation applies, and most of your debugging will be against the underlying trait rather than Arcanedev's package code.
Minimal setup:
composer require arcanedev/laravel-nestedset
Check composer.json immediately after install — it will likely pull kalnoy/nestedset (or lazychaser/laravel-nestedset) as a dependency, and that is the version you actually care about for compatibility.
Add columns to your table (via migration):
Schema::table('categories', function (Blueprint $table) {
$table->unsignedInteger('parent_id')->nullable()->index();
$table->unsignedInteger('_lft')->default(0)->index();
$table->unsignedInteger('_rgt')->default(0)->index();
});
Wire up the model:
use Arcanedev\NestedSet\NodeTrait;
class Category extends Model
{
use NodeTrait;
protected $fillable = ['name', 'parent_id'];
}
If Arcanedev's package does not expose its own trait name in your installed version, use Kalnoy\NestedSet\NodeTrait; is the fallback — verify in vendor/arcanedev/laravel-nestedset/src/.
First use case to prove it works — a category tree:
$root = Category::create(['name' => 'Root']);
$root->saveAsRoot();
$child = new Category(['name' => 'Child']);
$child->appendToNode($root)->save();
Category::whereIsRoot()->first()->getDescendantsAndSelf()->toTree();
If toTree() returns a flat array or bounds are wrong, jump straight to Gotchas — you almost certainly have an out-of-sync tree, not an API misuse.
Building a tree bottom-up vs top-down. Two valid styles; pick one per codebase so reviewers don't have to reason about both:
// Top-down: create the parent, then append children
$child->appendToNode($parent)->save();
// Bottom-up: create everything flat, then attach
$child->save();
$child->makeChildOf($parent); // also updates parent_id + bounds in one go
appendToNode() returns $this (fluent) but requires a subsequent save(); makeChildOf() persists immediately. Mixing them up is the #1 source of orphaned nodes.
Ordered sibling insertion. For menus and CMS taxonomies you almost never want "append to end":
$node->insertBefore($sibling);
$node->insertAfter($sibling);
$node->prependToNode($parent);
Use these instead of manually fiddling with _lft/_rgt. Sort order in toTree() defaults to _lft, so insertion order == display order for free.
Scoped trees (multi-tenant). Rarely used but critical when you need it — e.g. one category tree per store:
class Category extends Model
{
use NodeTrait;
protected $guarded = ['store_id'];
}
Category::scoped(['store_id' => $store->id])->saveAsRoot();
Category::scoped(['store_id' => $store->id])->whereIsRoot()->get()->toTree();
Remember to pass the scope on every call, including getDescendantsAndSelf().
Reading trees for a view layer. Three shapes, pick deliberately:
// Nested arrays with ->children — good for <ul> recursion
$tree = Category::whereIsRoot()->get()->toTree();
// Flat list with ->depth — good for indented <select> dropdowns
$flat = Category::get()->toFlatTree();
// Subtree only — good for category detail pages
$subtree = $category->getDescendantsAndSelf()->toTree();
Avoid getDescendants()->toTree() on a node — it returns descendants as roots of their own forest and drops the parent linkage you probably wanted.
Move operations in transactions. A move rewrites many rows; wrap it:
DB::transaction(function () use ($node, $target) {
$node->moveTo($target, 'child'); // 'child', 'left', 'right'
});
For UI-level drag-and-drop, translate the drop event into moveTo($target, $position) and nothing else — do not compute bounds client-side.
Bulk loading without N+1. toTree() is efficient, but rendering $node->parent->name in a loop is not:
Category::with('parent')->whereIsRoot()->get()->toTree();
Better still, use withDepth() on the query and read $node->depth in views rather than walking parents.
Rebuilding after imports / raw SQL. If you seed trees with DB::table('categories')->insert(...) or any query-builder mass update, bounds are junk. Always finish with:
Category::rebuild();
// or per-scope:
Category::scoped(['store_id' => $id])->where('store_id', $id)->get()->toTree(); // verify
Run Category::isBroken() / Category::countErrors() in a test assertion after every import — that's your cheapest regression net.
Laravel/PHP compatibility is the real risk. A 2017 release targets Laravel 5.3–5.5 and PHP 7.0/7.1. On Laravel 8+/PHP 8 you will hit typed-property, Arr/Str helper, and Model::resolveRelationUsing signature changes inside the trait. Do not sink hours into compatibility shims — since this package is a wrapper, point Composer at kalnoy/nestedset directly and keep the same API. That's the single most important decision in this assessment. Treat Arcanedev's package as a historical snapshot, not a maintained dependency.
Nested sets assume exclusive write access. Two concurrent appendToNode()->save() calls can corrupt the whole tree, not just the two nodes involved. Lock the table (or the scoped subset) inside the transaction for writes that move nodes:
DB::transaction(function () { Category::lockForUpdate()->get(); /* then move */ });
For high-write models, nested sets are the wrong data structure — a closure table or parent_id-only adjacency list with recursive CTEs is safer.
Soft deletes silently break _lft/_rgt invariants. Deleting a node soft-deletes it but does not collapse the gap. If your queries scope out trashed rows, getDescendants() can return a subtree whose parent looks missing. Either hard-delete leaf nodes, or call ->delete() inside a move/rebuild routine, and always re-verify with isBroken().
Mass updates bypass the trait. Category::where('parent_id', $x)->update([...]) will happily destroy your tree because it never touches _lft/_rgt. Same for ->insert() and ->truncate(). Grep for these in code review; forbid them on nested-set models or pair them with an explicit rebuild().
Column naming is configurable, so read config/nestedset.php before debugging. If someone renamed _lft→lft in config, every Stack Overflow answer you find will be misleading. $node->getLftName() and $node->getRgtName() tell you the truth at runtime.
Indexes matter more than you think. Without a composite index on (_lft, _rgt), getDescendants() degrades badly on large tables. The migration snippet above adds single-column indexes; add the composite one if your tree exceeds a few thousand rows.
Debugging toolbelt — keep these four one-liners handy:
dd($node->getBounds()); // lft/rgt sanity check on one node
Category::isBroken(); // global tree integrity
Category::countErrors(); // number of bad nodes
$node->getAncestors()->pluck('name'); // confirm parent chain
When toTree() "does nothing," it's almost always isBroken() === true from a prior mass update — fix the data, don't rewrite the query.
Extension point worth knowing: the trait fires moving/moved model events (per Kalnoy). That's the correct hook for cache invalidation or audit logging on tree changes — not the saved event, which fires per-row during a move and will flood your listeners.
How can I help you explore Laravel packages today?