lunarphp/nestedset
A Laravel package for working with nested set trees. Store and query hierarchical data such as categories, menus, and org structures efficiently, with support for creating, moving, and reordering nodes using left and right indexes. Ideal for fast ancestry and descendant lookups.
This is Lunar's fork of kalnoy/nestedset — a nested set implementation for Eloquent models (adjacency list parent_id plus _lft/_rgt bounds). It gives you fast, single-query subtree reads and cheap reordering, at the cost of writes that touch multiple rows. If you already know kalnoy/nestedset, the API is identical; only the namespace, PHP/Laravel floors, and some hardening have changed.
composer require lunarphp/nestedset — note the hard requirement of Laravel 12/13. If you're on Laravel 10 or 11, stay on kalnoy/nestedset; this fork will not install.parent_id (nullable FK/index), _lft and _rgt (unsigned int, indexed), and optionally depth and a scope column, then index (_lft, _rgt).use Kalnoy\Nestedset\NodeTrait; // verify the namespace after install
protected $fillable = ['name', 'parent_id'];
public function getLftName() { return '_lft'; }
public function getRgtName() { return '_rgt'; }
$child->appendToNode($root)->save(); and read back with Category::ancestorsOf($id)->get() or Category::descendantsOf($id)->get().The README is the primary reference; the NodeTrait source is small and worth skimming once so you know exactly which queries each method runs.
Creation and placement. Never set parent_id and save. Use the node API: appendToNode, prependToNode, insertBeforeNode, insertAfterNode, then save(). For bulk seeding, wrap in a transaction and consider saveQuietly() so tree events don't cascade.
Reading trees efficiently. descendantsOf($id) returns a query builder — constrained to the node's bounds — so you can chain ->where('active', 1) and still get correct subtree semantics in one query. ancestorsOf walks outward; siblingsAndSelf, children, leaves, root cover the rest. Use toTree() / toFlatTree() to hydrate an array-of-arrays in one hit rather than lazy-loading children in a loop (which is the classic N+1 in tree UIs).
Moving and reordering. moveTo, moveToStart, moveToEnd, moveToSibling, moveBefore, moveAfter. Each is a multi-row UPDATE on _lft/_rgt — always inside DB::transaction() when the move is part of a larger operation (e.g. drag-and-drop reorder plus an audit log).
Scoped trees. For multi-tenant or per-site trees, override getScopeAttributes() to return ['site_id' => ...] and index accordingly. Every query the trait builds is automatically constrained, so you don't have to remember where('site_id', ...) on relationship methods.
Deletion semantics. Default behaviour promotes children to the deleted node's parent (delete() repairs bounds). Call forceDelete() to take the whole subtree, or deleteDescendants() for a subtree prune. Soft-deleting a model without repairing the tree leaves holes — see gotchas.
API/consumer layers. Order by _lft for display, and use descendantsAndSelf when you need the node in the same result set. Depth is often the cheapest way to render indentation or limit nesting in validation ($node->depth).
moveTo/delete repair query sees corrupts left/right bounds. Test move and delete paths specifically if you have SoftDeletes, ActiveScope, or a tenant scope on the model._lft = 0 rows after reorders, that's the bug this addresses. Run a full rebuild of existing production trees after upgrading rather than assuming old data is clean.parent_id or _lft/_rgt outside the API desynchronise the tree. If you must, follow with a rebuild and verify with the package's check/repair commands (list them with php artisan list after install — command names are worth confirming for this version).orderBy on tree queries. Ordering by anything other than _lft during a read that then gets used for a move produces wrong bounds; apply presentation ordering after toTree().DB::transaction() with a pessimistic lock on the root, or a queue with a single worker, is the pragmatic fix._lft/_rgt, so descendants queries return them unless scoped. Decide explicitly whether your tree is "live only" or "includes trashed" and keep that consistent between display and repair logic.getLftName()/getRgtName()/getParentIdName()/getDepthName() let you match an existing schema; the moving/moved model events are the clean hook for cache invalidation of your tree's rendered output.setUp(), then assert on _lft/_rgt after each mutation. Bound assertions catch corruption far earlier than a rendered UI will.How can I help you explore Laravel packages today?