Search by

webx-ui / nested-set

Ahabzig

Nested set (modified preorder tree traversal) for Eloquent models: ordered trees with cheap subtree reads.

Package info

github.com/webx-ui/nested-set

Homepage

Issues

pkg:composer/webx-ui/nested-set

Statistics

Installs: 472

Dependents: 4

Suggesters: 0

Stars: 0

v0.41.0 2026-09-16 17:03 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.