webx-ui / nested-set
Nested set (modified preorder tree traversal) for Eloquent models: ordered trees with cheap subtree reads.
Requires
- php: ^8.3
- illuminate/database: ^13.0
- illuminate/support: ^13.0
Requires (Dev)
- orchestra/testbench: ^11.0
- phpunit/phpunit: ^12.0 || ^13.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v0.35.1
- v0.35.0
- v0.34.0
- v0.33.0
- v0.32.1
- v0.32.0
- v0.31.0
- v0.30.0
- v0.29.1
- v0.29.0
- v0.28.0
- v0.27.2
- v0.27.1
- v0.27.0
- v0.26.1
- v0.26.0
- v0.25.1
- v0.25.0
- v0.24.1
- v0.23.0
- v0.22.1
- v0.22.0
- v0.21.0
- v0.20.1
- v0.20.0
- v0.19.0
- v0.18.0
- v0.17.0
- v0.16.2
- v0.16.1
- v0.16.0
- v0.15.0
- v0.14.1
- v0.14.0
- v0.13.0
- v0.12.1
- v0.12.0
- v0.11.0
- v0.10.0
- v0.9.0
- v0.8.0
- v0.7.0
- v0.6.0
- v0.5.0
- v0.4.0
- v0.3.1
- v0.3.0
- v0.2.0
- v0.1.0
This package is auto-updated.
Last update: 2026-09-24 06:24:49 UTC
README
Nested set (modified preorder tree traversal) for Eloquent models.
Every node stores the bounds of its own subtree, so reading a whole branch — or a breadcrumb trail — is a single indexed query instead of one query per level. That is what makes a category tree, a page tree or a folder tree usable in an admin panel where the whole tree is on screen.
Part of WebX UI. Used on its own it needs nothing but Eloquent.
Requirements
- PHP 8.3+
- Laravel 13 (
illuminate/database,illuminate/support)
Install
composer require webx-ui/nested-set
Schema
use Illuminate\Database\Schema\Blueprint; Schema::create('categories', function (Blueprint $table): void { $table->id(); $table->string('name'); $table->nestedSet(); // parent_id, lft, rgt, depth + indexes $table->timestamps(); });
Without the Laravel service provider, call the helper directly:
use WebxUi\NestedSet\NestedSet; NestedSet::columns($table);
lft and rgt are signed integers on purpose: moving a subtree parks it in negative bounds
before putting it back, and an unsigned column would reject that halfway through the move.
Model
use Illuminate\Database\Eloquent\Model; use WebxUi\NestedSet\HasNestedSet; class Category extends Model { use HasNestedSet; }
Different column names? Override getLftName(), getRgtName(), getParentIdName() or
getDepthName().
Placing nodes
A saved model with no placement becomes the last root. Everything else is explicit:
$root = Category::create(['name' => 'Catalogue']); $phones = new Category(['name' => 'Phones']); $phones->appendTo($root); // last child $sale = new Category(['name' => 'Sale']); $sale->prependTo($root); // first child $tablets = new Category(['name' => 'Tablets']); $tablets->insertAfter($phones); $phones->up(); // swap with the previous sibling $phones->down(); // swap with the next one $phones->saveAsRoot(); // lift a whole branch out to the top level
The same calls move a node that already exists, subtree and all. Each one runs in a transaction;
moving a node into its own subtree throws NestedSetException.
Detached nodes
A node can exist before anybody has decided where it goes:
$draft = new Page(['name' => 'Untitled']); $draft->saveDetached(); // saved, has an id, is in no tree $draft->isDetached(); // true $draft->appendTo($parent); // joins the tree now, as an insert
This is what "new page" creates — a draft that can be previewed and autosaved without moving
half the table's bounds to make room for a record that may be thrown away. A detached node has
the bounds 0/0, which no placed node can have, so the columns stay what they are. It has no
parent, ancestors, siblings or descendants; ordered(), roots(), fixTree() and
checkTreeIntegrity() leave it alone; Page::query()->detached() lists them and placed()
the rest. Nothing can be placed relative to a detached node, and a placed node cannot be
detached again.
Reading
$node->parent; // relation $node->children; // relation, ordered $node->ancestors()->get(); // root first $node->descendants()->get(); // the whole branch below, ordered $node->siblings()->get(); $node->pathFromRoot(); // ancestors + the node itself, for breadcrumbs $node->isRoot(); $node->isLeaf(); $node->isChildOf($other); $node->isDescendantOf($other); Category::query()->roots()->ordered()->get();
To hand a whole tree to the front end in one query:
$tree = Category::toTree(Category::query()->ordered()->get());
Roots come back with their children relation filled in, recursively — which is the shape
<wx-tree> and the WebX UI table tree expect.
Several trees in one table
class Page extends Model { use HasNestedSet; public function getNestedSetScopeAttributes(): array { return ['site_id']; } }
Bounds are then counted per site, and placing a node from one site next to a node from another throws instead of silently merging the trees.
Maintenance
Category::checkTreeIntegrity(); // [] when healthy, otherwise one line per problem Category::fixTree(); // rebuild lft/rgt/depth from parent_id, returns rows fixed
fixTree() keeps the current order and rebuilds orphans as roots. Both take a scope array
(Page::fixTree(['site_id' => 1])) for scoped models.
Deleting
Deleting a node deletes its subtree and closes the gap it leaves. The descendants are removed with one query, so their model events do not fire — delete them one by one first if you rely on those.
Soft deletes are refused by default: a node that vanishes from every query while its bounds are
reclaimed leaves its children outside their parent, and restore() puts back a row that no
longer fits anywhere. A model using SoftDeletes throws unless the delete is a forceDelete().
A model that wants them says so, and takes on the other half:
public function softDeletesInTree(): bool { return true; }
Then a soft delete leaves the bounds alone — the trashed node still occupies its place, its
descendants still stand inside it, and nothing happens to them unless the model makes it happen.
webx-ui/module-pages is what that looks like: deleting a page trashes its branch node by node,
so that every address in it is released, and restoring one brings back exactly what went with it.
Licence
MIT.