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

Nested Set Laravel Package

webx-ui/nested-set

A Laravel package for managing hierarchical data with the nested set model, offering tree-friendly operations for building, querying, and organizing parent-child records in Eloquent models.

View on GitHub
Deep Wiki
Context7

Getting Started

Verify the package before you trust it. webx-ui/nested-set has zero stars and zero dependents, so there is no Packagist reputation, no README you can lean on, and no issue tracker history to mine. Install it and then immediately open vendor/webx-ui/nested-set/src/:

composer require webx-ui/nested-set
ls vendor/webx-ui/nested-set/src

Answer four questions from the source before you write a single line of app code:

  1. What is the trait's fully-qualified name, and what columns does it assume (lft, rgt, depth, parent_id, tree_id — or something else)?
  2. Which methods exist for ancestors(), descendants(), children(), siblings(), and moves?
  3. Do structural writes wrap themselves in transactions, and do they call lockForUpdate()?
  4. Is there a rebuild/validate entry point — a command, a static method, or both?

Everything below assumes the conventional surface (lft, rgt, depth, parent_id, tree_id). Rename to match whatever the source declares.

Write the migration immediately, scope column included. The tree_id column is the one people add later and regret — retrofitting it means rebuilding every set from scratch:

Schema::table('categories', function (Blueprint $table) {
    $table->unsignedInteger('tree_id')->default(1);
    $table->unsignedInteger('parent_id')->nullable();
    $table->unsignedInteger('lft')->nullable();
    $table->unsignedInteger('rgt')->nullable();
    $table->unsignedInteger('depth')->default(0);

    $table->index(['tree_id', 'lft']);
    $table->index(['tree_id', 'rgt']);
    $table->index(['tree_id', 'depth']);
});

Keep the bound columns nullable in the first migration so you can backfill from an existing parent_id without downtime, then tighten in a follow-up.

First use case — prove it with a category menu. This is the smallest thing that exercises insert, read, and breadcrumbs:

use WebxUi\NestedSet\HasNestedSet;

class Category extends Model
{
    use HasNestedSet;

    protected $fillable = ['name', 'parent_id', 'tree_id'];
}

$root  = Category::create(['name' => 'Catalog', 'tree_id' => 1]);
$root->makeRoot();                    // or saveAsRoot() — check the source

$shoes = Category::create(['name' => 'Shoes', 'parent_id' => $root->id, 'tree_id' => 1]);
$boots = Category::create(['name' => 'Boots', 'parent_id' => $shoes->id, 'tree_id' => 1]);

// Whole menu in one query
$menu = Category::where('tree_id', 1)->orderBy('lft')->get();

// Breadcrumbs in one query
$crumbs = $boots->ancestors();

If orderBy('lft') gives you a correctly ordered menu and ancestors() returns the chain in one query, the package is doing its job. If it isn't, stop now — you do not want to discover the mismatch after a production backfill.

Add php artisan tinker as your first debugging tool. Before any UI work, seed a five-node tree in the REPL and dump id, parent_id, lft, rgt, depth for every row. You want to internalise the numbering — preorder, contiguous, rgt - lft odd for a node with children — because every bug you will meet is a violation of that invariant.

Implementation Patterns

Put the trait behind one internal class. This is the highest-leverage habit when adopting a zero-adoption package. Controllers, admin screens, Blade partials, and API resources should never touch HasNestedSet directly:

interface TreeRepository
{
    public function tree(int $treeId): Collection;
    public function breadcrumbs(int $nodeId): Collection;
    public function move(int $nodeId, ?int $newParentId, ?int $position = null): void;
    public function rebuild(int $treeId): void;
}

final class EloquentTreeRepository implements TreeRepository
{
    public function tree(int $treeId): Collection
    {
        return Category::where('tree_id', $treeId)->orderBy('lft')->get();
    }

    public function breadcrumbs(int $nodeId): Collection
    {
        return Category::findOrFail($nodeId)->ancestors();
    }
    // ...
}

Swapping to kalnoy/nestedset, or forking the trait, becomes a one-file change instead of a codebase-wide grep.

Render trees from one query, assemble in PHP. The entire point of the storage model is that the menu is one indexed read, not a read per level:

$nodes     = $this->tree($treeId);            // one query, ordered by lft
$byParent  = $nodes->groupBy('parent_id');

$flatten = function ($parentId, $level = 0) use (&$flatten, $byParent) {
    foreach ($byParent[$parentId] ?? [] as $node) {
        yield ['node' => $node, 'level' => $level];
        yield from $flatten($node->id, $level + 1);
    }
};

A recursive generator over an already-hydrated collection is O(n) and touches the DB once. Reaching for ->children() inside a loop is exactly the N+1 you bought this package to avoid.

Use the bounds arithmetically for widgets. Subtree size is ($rgt - $lft - 1) / 2; a "15 descendants" badge or a subtree count is a scalar query with no hydration:

$subtreeCount = Category::where('tree_id', $treeId)
    ->where('lft', '>', $node->lft)
    ->where('rgt', '<', $node->rgt)
    ->count();

This is where the pattern earns its keep on hot paths — the sidebar count on every page load costs one index scan.

Always scope by tree_id, explicitly. Don't assume the trait adds a global scope; assume it doesn't. On a multi-tenant install, one missing where('tree_id', ...) is a silent cross-tenant data leak. Pick one policy — a global scope on the model, or a mandatory filter in the repository — and enforce it in code review.

Expose moves as named intents, inside a transaction, with locking. Never expose a raw parent_id write. Give the UI moveBefore, moveAfter, moveToParent endpoints:

public function move(int $nodeId, int $newParentId, ?int $position = null): void
{
    DB::transaction(function () use ($nodeId, $newParentId, $position) {
        $node   = Category::lockForUpdate()->findOrFail($nodeId);
        $parent = Category::lockForUpdate()->findOrFail($newParentId);

        $node->moveTo($parent, $position);   // whatever the trait exposes
    });
}

Structural writes touch many rows; two concurrent moves on the same tree will deadlock or corrupt. Serialise them yourself — a single-worker queue, an advisory lock per tree_id, or lockForUpdate() on the affected range.

Backfill from an existing parent_id in phases. Nullable bound columns → batched transactional pass ordered by parent chain → a validation query that compares adjacency-derived parents against the derived lft/rgt → tighten to NOT NULL. Quarantine orphans, cycles, and cross-tenant parents before the pass, not during it.

Ship an integrity command on day one. A scheduled job that walks each tree_id and asserts lft/rgt form a contiguous 1..2n preorder turns "the menu looks wrong" from a day of on-call archaeology into a five-minute check:

foreach ($treeIds as $treeId) {
    $rows = DB::table('categories')->where('tree_id', $treeId)
        ->orderBy('lft')->pluck('lft', 'id');

    abort_unless(
        $rows->values()->all() === range(1, $rows->count()),
        500, "Tree {$treeId} numbering is corrupt"
    );
}

Test the trait, not just your repository. Write a Pest/PHPUnit test per operation — insert at root, insert child, move before sibling, move to another parent, delete, delete subtree — that asserts the full id → (lft, rgt, depth) map after the operation. These are the regression tests that make later upgrades a port rather than a gamble. Run them against the DB engine you deploy on.

Gotchas and Tips

Every structural write rewrites a contiguous range. Inserting near the root of a 20k-node tree shifts lft/rgt on most of the table. If drag-and-drop reordering is a core user interaction rather than an admin chore, this is the wrong storage model — that's a product-level constraint, not something you tune your way out of.

Bulk writes bypass the trait and silently corrupt the tree. Category::where(...)->update(['parent_id' => $x]) leaves lft/rgt stale and every subsequent subtree read is wrong. Ban raw writes on tree models in code review; where you must (imports, data fixes), run the rebuild afterwards as a matter of course.

Soft deletes are the classic trap. A soft-deleted node that keeps its numbering leaves an arithmetic gap that breaks ancestor/descendant range logic; one excluded by a global scope makes the bounds look non-contiguous. Decide explicitly whether deletes are hard (remove and renumber) and verify the trait's actual behaviour — it is very unlikely to be tested for soft deletes.

Assume the package does no locking. There is no documented concurrency story. Add lockForUpdate() on the affected nodes yourself and serialise per tree. If you see a deadlock, you have a missed lockForUpdate() — not a package bug.

Cross-tree moves need a decision, not an assumption. Moving between tree_id values means pulling the range out of the old tree, closing the gap, and inserting into the new one. Confirm the trait implements it; if it doesn't, reject the operation at the controller rather than shipping a button that corrupts data.

Index order matters more than index presence. (tree_id, lft) and (tree_id, rgt) are what make range scans fast; a bare lft index won't be used once tree_id is in the predicate. EXPLAIN your descendants query in staging — don't assume that because it returns rows it's using the index.

Driver differences will bite. Any raw UPDATE ... ORDER BY, locking hint, or affected-rows assumption in the source behaves differently on MySQL, Postgres, and SQLite. CI on SQLite will pass while production under MySQL fails. Point your test suite at the deployed engine before you commit to the model.

Keep a rebuild command as a documented escape hatch. php artisan nested-set:rebuild categories --tree=1, plus a runbook entry for when the integrity check fires. Practise it on staging so you know the blast radius and whether it can run with writes paused.

Debugging recipe. When the tree looks wrong: (1) run the integrity check first, always; (2) if numbering is contiguous but the order is wrong, the write path is the suspect — grep for raw update() calls on the model; (3) if numbering is non-contiguous, a delete or a cross-tree move went through an unguarded path; (4) if the numbers look fine but the UI disagrees, the bug is in your in-memory assembly, not the package.

Extension points to know. The trait is the integration surface: if you need per-model table/column overrides, subclass or shadow the trait method rather than patching vendor code — but vendor it (see below) so a patch is a merged commit, not an override. Model events (saving, deleted) are tempting for auto-moves, but they'll fire on backfills and bulk writes too; do structural work in the repository, not in observers.

Pin and vendor, and name an owner. Zero stars and zero dependents means no CVE feed, no regression signal, no support path. Pin the exact commit in composer.json, copy the source into your repo, and record an internal owner with a written patch policy in the README of the wrapper package. Treat upgrades as a deliberate port, not a routine composer update.

Run the parallel spike against kalnoy/nestedset. Because everything goes through TreeRepository, swapping implementations is cheap. If the mature library's API is anywhere close for your use case, take it — the maturity delta is worth more than any ergonomic win here, and you'll find out in days rather than quarters.

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.
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
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