vusys/laravel-nestedset
A modern Laravel nested-set implementation for hierarchical Eloquent data, featuring strict types, PHPStan level 9, atomic CASE-WHEN mutations, multi-tree scoping, and soft deletes. Supports PHP 8.3 and Laravel 11, 12, or 13.
Install the package and confirm the Laravel version constraint matches your app:
composer require vusys/laravel-nestedset
Add the nested-set columns to the table that will hold the hierarchy. The minimum is a parent key plus the left/right boundaries:
Schema::table('categories', function (Blueprint $table) {
$table->unsignedBigInteger('parent_id')->nullable()->index();
$table->unsignedInteger('_lft')->default(0)->index();
$table->unsignedInteger('_rgt')->default(0)->index();
});
Run the migration before seeding any rows, otherwise the tree columns will not line up.
Attach the node trait to your Eloquent model and import the interface/trait pair the package expects (usually a NodeTrait and the matching contract):
use Vusys\LaravelNestedSet\NodeTrait;
class Category extends Model
{
use NodeTrait;
protected $fillable = ['name', 'parent_id'];
}
Where to look first: read the package's docs/ directory (or the README's API section) for the scope names (ancestors, descendants, siblings, leaves) and the move methods (appendToNode, prependToNode, insertBeforeNode, insertAfterNode, makeRoot). These are the surface you will use 90% of the time.
First use case: a category/product tree. Seed roots with Category::create(['name' => 'Root']), then attach children:
$root = Category::create(['name' => 'Electronics']);
$root->children()->create(['name' => 'Phones']);
Fetch the whole tree with Category::toTree() (or ->get()->toTree()) and render it recursively in a Blade partial.
Render a tree in a controller. Load once, build in memory, avoid N+1:
$tree = Category::defaultOrder()->get()->toTree();
return view('categories.index', compact('tree'));
Then use a recursive Blade component that walks $node->children.
Scoped queries instead of full-tree loads. For detail pages, only pull the branch you need:
$category = Category::findOrFail($id);
$breadcrumbs = $category->ancestors()->defaultOrder()->get();
$subtree = $category->descendants()->defaultOrder()->get();
Building an admin drag-and-drop editor. Each drop maps to a move operation. Wrap it in a transaction and re-read the node:
DB::transaction(function () use ($node, $parent) {
$node->appendToNode($parent)->save();
});
$node->refresh();
Return the affected subtree so the frontend can re-render without a full reload.
Multi-tree / per-tenant hierarchies. If several independent trees live in one table, add a discriminator column (e.g. tenant_id) and scope every query. The package's default order and move operations assume a single tree unless you constrain them; enforce this with a base scope on the model.
API resources. Use toTree() with a resource collection that recursively transforms children. Keep _lft/_rgt out of the JSON payload—they leak implementation detail and confuse clients.
Soft deletes. If your model uses SoftDeletes, ensure delete/restore operations still keep the tree consistent and that scopes exclude trashed nodes from descendants. Test deletions specifically—removing a node with children should either cascade or move children up, per your domain rules.
Seeding and bulk imports. For large imports, build rows flat and rebuild the tree columns in one pass rather than calling appendToNode per row (each move is a write-heavy operation).
_lft/_rgt values. Do not use this package for high-write workloads (e.g. live comment threads with constant inserts); prefer adjacency lists or closure tables there. Use it for mostly-static hierarchies like categories, menus, org charts.->lockForUpdate()) or guard with a queue.appendToNode(...)->save(), the in-memory _lft/_rgt on the model and its ancestors are stale. Call $node->refresh() before reading ancestors()/descendants().insert()/upsert(). Direct inserts bypass the boundary recalculation and silently corrupt the tree. If you must, follow up with a tree rebuild (check the docs for a rebuild/fixTree-style command).lft/rgt instead of _lft/_rgt, override them via the documented properties/constants on the trait rather than renaming columns ad hoc.descendants() returns wrong nodes, toTree() drops branches. dd(Category::defaultOrder()->get(['id','parent_id','_lft','_rgt'])) to inspect boundaries, then run the rebuild command. Add a test that asserts parent/child relationships after every migration that touches the table.->with('children') only loads one level. For deep trees either use descendants scoping or recursively load; with('descendants') can be expensive on wide trees.php artisan vendor:publish) and check for the table/column conventions and any default_order toggle—surprises usually come from an unpublished config.kalnoy/nestedset and pin the version in composer.json. Audit the docs/ and tests before committing; keep your own integration tests around tree moves and deletions so a future dependency swap is a drop-in change.saving, deleted) if you need audit logging around moves—just don't reorder the tree inside those events.How can I help you explore Laravel packages today?