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
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.
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 not supported: a soft-deleted node would stay in the tree while its bounds were
reclaimed. A model using SoftDeletes throws unless the delete is a forceDelete().
Licence
MIT.