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

Orm Nested Set Laravel Package

mindy/orm-nested-set

Nested set behavior for the Mindy ORM, making it easy to store and query hierarchical data in PHP. Supports tree creation, node moves, ancestor and descendant lookups, and efficient subtree queries with minimal database overhead.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install and pin

composer require mindy/orm-nested-set

Then pin it in composer.json ("mindy/orm-nested-set": "1.0.*" or a commit hash). With 1 star and 0 dependents you do not want an unconstrained ^ range pulling in a breaking change mid-sprint.

2. Read the trait before you write any model

The package is small. Open vendor/mindy/orm-nested-set/ and look for the behavior/trait class and its $leftColumn / $rightColumn (usually _lft / _rgt) and $parentColumn (parent_id) properties. Everything else — makeRoot, appendToNode, moveTo, ancestors, descendants — follows from those conventions, and confirming the names now saves an hour of guessing later. Note that the Packagist description ("The Mindy Orm File and Image Fields") is copy-pasted from a sibling package and is wrong; trust the repo name and source, not the description.

3. Migrate the tree columns

Schema::table('categories', function ($table) {
    $table->unsignedInteger('parent_id')->nullable();
    $table->unsignedInteger('_lft')->default(0);
    $table->unsignedInteger('_rgt')->default(0);

    $table->index(['_lft', '_rgt']);
    $table->index('parent_id');
});

Use unsignedInteger deliberately — signed 32-bit gives you ~2.1B nodes but negative values make corruption debugging miserable and can mask bugs where a -1 sentinel leaks in.

4. Attach the behavior

class Category extends \Mindy\Model
{
    use \Mindy\Orm\NestedSet\NestedSetTrait;

    public static function tableName() { return 'categories'; }
}

5. First use case: seed a root and append children

$root = Category::create(['name' => 'Catalog'])->makeRoot()->save();

$books = Category::create(['name' => 'Books']);
$books->appendToNode($root)->save();

Category::create(['name' => 'Fiction'])->appendToNode($books)->save();

The first real win to build toward is anything you render recursively — category trees, nested comment threads, menu builders, org charts, threaded forums. If you also need cheap writes, stop here and reconsider (see Gotchas).

Implementation Patterns

Fetch a whole subtree in one query

This is the reason to use nested set at all. A single range scan beats recursive parent_id walking:

$subtree = Category::objects()
    ->filter(['_lft__gte' => $node->_lft, '_rgt__lte' => $node->_rgt])
    ->order(['_lft' => 'asc'])
    ->all();

That returns the subtree already in depth-first order — no recursion, no N+1. Anything the trait offers for descendants() should compile down to this; if it doesn't (e.g. it loops in PHP), build the raw query yourself.

Build a hierarchy for rendering

Flatten-then-nest rather than nest-while-querying. Fetch ordered rows once, then fold by depth (level = count(ancestors)):

$rows = $q->all();                    // ordered by _lft
$stack = [];
$tree  = [];
foreach ($rows as $row) {
    $row->children = [];
    while ($stack && end($stack)->_rgt < $row->_lft) array_pop($stack);
    if ($stack) { end($stack)->children[] = $row; }
    else        { $tree[] = $row; }
    $stack[] = $row;
}

This is O(n) and avoids the classic nested-set trap of with('children') at every level.

Moving nodes — always in a transaction

Every insert/move rewrites _lft/_rgt for a band of rows. Make that atomic or you get a permanently unreadable tree:

$db->transaction(function () use ($node, $target) {
    $node->moveTo($target);   // trait does the lft/rgt arithmetic
});

If your DB supports it, take a pessimistic lock on the affected scope first (SELECT ... FOR UPDATE) or you'll interleave two concurrent moves and cross the bounds.

Scope trees per tenant

Very few real apps want one global tree. Add a scope_id / site_id column and always include it in every range query:

->filter(['scope_id' => $tenantId, '_lft__gte' => ..., '_rgt__lte' => ...])

The trait likely assumes a single tree, so this is the first thing you'll override.

Ordering siblings

Siblings are contiguous in _lft order. ORDER BY _lft is free; an ORDER BY name on top of it will break the depth-first guarantee your renderer relies on. If you need alphabetical siblings, sort at the children array level after the fold, not in SQL.

Integration with routing / breadcrumbs

Ancestors fall out as a second range query — useful enough to memoize per request:

$breadcrumbs = Category::objects()
    ->filter(['_lft__lt' => $node->_lft, '_rgt__gt' => $node->_rgt])
    ->order(['_lft' => 'asc'])->all();

Repair command

Sooner or later someone writes a script that inserts straight into the table. Ship a console command that walks the table by _lft, recounts, and rewrites _lft/_rgt in one pass. Treat it as part of the feature, not a nice-to-have.

Gotchas and Tips

Write cost is O(n), not O(1). Appending to the end of a large tree touches every row to the right of the insertion point. Nested set is a read-optimized structure. A category tree with occasional admin edits is perfect; a comment thread with thousands of inserts per minute is a bad fit — use adjacency list + WITH RECURSIVE or a materialized path there.

The package's age and traction are the real risk. One star, zero dependents, and a wrong description. Do not assume the trait is maintained against current Mindy ORM releases. Fork it into your src/ if you're committed, or at minimum wrap the public methods behind your own repository class so a swap is a one-file change.

Soft deletes corrupt nested set. A soft-deleted node still occupies its _lft/_rgt range, so its children keep hanging off a "missing" parent, and a hard-delete later shifts everything. Decide explicitly whether deleting a node re-parents children, cascades, or moves them up a level, and implement that in an overridden delete() rather than relying on the trait.

Corruption is silent. The tree does not throw when _lft/_rgt overlap — it just returns wrong rows. Add an assertion in your repair command and run it in CI against a fixture:

// for each row: lft < rgt, and (rgt - lft - 1) is even → subtree size is consistent

No unique index on _lft per scope. Two rows with the same _lft in one scope means two concurrent inserts won. A UNIQUE(scope_id, _lft) constraint turns a silent corruption into a loud transaction failure — add it.

parent_id is redundant and will drift. It's kept for convenience but _lft/_rgt are the source of truth. Never traverse by parent_id and never query by it inside tree logic; if it drifts, tree reads still work while parent_id-based ones lie.

Depth is computed, not stored. Every getLevel() is an ancestor count. If you filter "only top two levels" in a listing, materialize a level column and recompute it in the same transaction as any move — otherwise your listing silently drifts.

Concurrency under load. Two admins reordering a menu at the same time is the most common real-world corruption path. Serialize moves per scope with a lock row or advisory lock; don't rely on REPEATABLE READ alone.

Debug by dumping bounds, not by eyeballing the UI.

foreach ($q->order(['_lft' => 'asc'])->all() as $r) {
    printf("%s  [%d, %d]\n", str_repeat('  ', $r->getLevel()) . $r->name, $r->_lft, $r->_rgt);
}

Well-formed output is a properly indented staircase; anything else tells you exactly which move failed.

Eager-loading is a trap. with('children.children') on a nested set makes the whole point moot — you're back to N+1 and you've paid the write-cost premium for nothing. Load the ordered range once.

Extension points. The trait's protected helpers (the ones that do the _lft/_ rgt bulk updates — often moveLeft/moveRight/shift) are where you hook scoping and locking. Override them in your model rather than in the vendor file so upgrades stay possible.

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.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource