Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Nestedset Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

  • Stale attributes after a move. After 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.
  • A default order scope may be applied. If your 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.
  • Mass assignment. _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.
  • Never 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.
  • Concurrency. Inserts and moves are read-then-write sequences over many rows; under parallel writes you can interleave two moves and corrupt the bounds. Serialize writes (queue with a single worker, advisory/row locks, or DB::transaction with lockForUpdate on the root) if you have real write concurrency.
  • Eager loading depth. 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.
  • Column naming is overridable via 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).
  • Fork risk is a real day-to-day concern. 1 star, 0 dependents, single-maintainer fork: pin the exact version in 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.
  • Useful extension points: 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core