lychee-org/nestedset
Lychee's Nested Set package brings the nested set model to Laravel's Eloquent, so you can store and query hierarchical data—trees, categories, menus—with fast ancestor and descendant lookups and an expressive API for creating, moving, and traversing nodes.
Install and check the namespace first. This is a LycheeOrg fork of kalnoy/nestedset kept alive for Laravel 12. Before writing any code, open vendor/lychee-org/nestedset/src/NodeTrait.php and confirm the trait namespace (LycheeOrg\NestedSet\NodeTrait vs. the upstream Kalnoy\Nestedset\NodeTrait) — the fork is largely API-compatible, so upstream kalnoy/nestedset docs and StackOverflow answers apply almost verbatim, but the use statement does not. Don't install both packages side by side; they declare the same class names and you'll get confusing autoload behaviour.
composer require lychee-org/nestedset
php artisan vendor:publish --provider="LycheeOrg\NestedSet\NestedSetServiceProvider" # if a config is shipped
Schema. Default column names are _lft, _rgt, parent_id. You add them yourself (there's usually no shipped migration):
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('parent_id')->nullable();
$table->unsignedBigInteger('_lft')->default(0);
$table->unsignedBigInteger('_rgt')->default(0);
$table->string('name');
$table->timestamps();
$table->index(['_lft', '_rgt']);
$table->index('parent_id');
});
Model. One trait, nothing else required:
use LycheeOrg\NestedSet\NodeTrait;
class Category extends Model
{
use NodeTrait;
protected $fillable = ['name']; // parent_id/_lft/_rgt are guarded by the trait
}
First use case — build and read a menu tree:
$root = Category::create(['name' => 'Docs']); // becomes a root
$guide = Category::create(['name' => 'Guide'])->appendToNode($root)->save();
Category::create(['name' => 'Install'])->appendToNode($guide)->save();
$tree = Category::whereIsRoot()->first()->toTree(); // nested `children` collections
toTree() is the single call you'll use most: it takes an already-fetched flat set and nests it in memory, so Category::get()->toTree() gives you an entire forest in one query. Start there before reaching for recursive queries.
Always create through the tree API, never by setting parent_id alone. $model->parent_id = $x; $model->save(); writes the row but leaves _lft/_rgt at 0 and corrupts every subsequent query. Use the node operations: appendToNode(), prependToNode(), insertBeforeNode(), insertAfterNode(), makeRoot() / saveAsRoot(). They return the node so they chain into ->save().
Query builder style for read endpoints. descendants(), ancestors(), siblings() are relations returning query builders, so you can constrain and paginate:
$node->descendants()->withDepth()->where('active', true)->orderBy('name')->get();
$node->ancestors()->defaultOrder()->get(); // root → parent breadcrumb
$node->children()->count();
$node->isDescendantOf($other);
withDepth() injects a depth column (relative to the queried node) — ideal for rendering an indented <select> or a sitemap without recursion.
Moving subtrees is a single call, but wrap it. $subtree->appendToNode($newParent)->save() re-numbers _lft/_rgt for everyone to the right of the moved range. Do it inside DB::transaction(), ideally with a row lock on the roots, and refresh() the moved node plus any models you're holding — their in-memory bounds are stale immediately after the move.
Bulk import: don't loop appendToNode. Each insert shifts _lft/_rgt for the remainder of the tree, so N inserts is O(N²) writes. For seeders and imports build a nested array and use Category::rebuildTree($data, $delete = false), or wrap a loop in Model::withoutEvents(fn () => ...) inside one transaction with Model::unguarded().
Rendering. For repeated HTML menus, either toTree() once and use a Blade recursive partial on $node->children, or fetch descendants()->withDepth()->defaultOrder()->get() and render a flat list keyed on depth. The second scales better and avoids deep recursion.
Multi-tenant / multi-tree tables. If several independent trees share one table (per-tenant roots, per-user comment threads), add your own tree_id column and apply a global scope on the model. The trait's range predicates rely on non-overlapping _lft/_rgt across the table, which holds as long as every insert goes through the package — but scoping explicitly keeps descendants() correct if anything ever desyncs and makes your indexes useful.
Repair affordances are part of normal operation. Expose an artisan command or scheduled job that runs Category::isBroken() and Category::fixTree(). Any raw DELETE, DB-level ON DELETE CASCADE, or manual UPDATE on parent_id from a DBA or an older code path will break the structure, and fixTree() is the cheap fix.
appendToNode(...)->save(), unrelated in-memory nodes keep their old _lft/_rgt. Reload with refresh() or re-query; never compare bounds of previously loaded models against a freshly moved tree.orderBy('name') appears to be ignored on queries against the model, a _lft ordering scope is winning — use reorder() to replace it rather than adding a second orderBy.delete() cascades to descendants and fires model events for each one. Deleting a 5,000-node subtree is 5,000 deletes; use a transactional bulk path if you need speed, or you'll be surprised by observer side effects (queued jobs, file cleanup) firing en masse. With SoftDeletes, deleted rows still occupy _lft/_rgt, so descendants() includes trashed nodes unless you filter them._lft, _rgt, and parent_id are guarded by the trait. If you override $guarded = [] for convenience, you've re-opened the "set parent_id manually" footgun described above.truncate() / DELETE / UPDATE the tree columns outside the package. This is the #1 source of corrupted trees. Run fixTree() after any such operation and assert with isBroken() in a test.DB::transaction with lockForUpdate on the root) if you have real write concurrency.with('children') only loads one level. For a whole forest do Category::get()->toTree() for one query, or with('children.children') for a bounded depth. toTree() on a 100k-row table is a memory problem, not a query problem — chunk by _lft range for large subtrees.getLftName(), getRgtName(), getParentIdName() if you're adopting this into an existing schema with lft/rgt (older kalnoy v4 used those names without the underscore — a common source of "column not found" when copying snippets from old blog posts).composer.json, read the diff against upstream kalnoy/nestedset before upgrading, and keep your tree logic thin (a repository/service wrapping the trait) so a swap back to upstream — or to a maintained alternative — is a mechanical change rather than a rewrite.NodeTrait methods are ordinary public/protected methods, so wrapping or overriding getLftName()-style accessors and adding model scopes is the intended customization path. Keep the trait last in the use list if your model composes other traits that touch boot/newQuery.How can I help you explore Laravel packages today?