Scoped Trees
Forests of independent trees in one table.
A scoped model partitions one table into many independent trees,
keyed by one or more columns. The classic case is per-tenant or
per-site menus: every customer has their own menu hierarchy, but
they all live in one menu_items table and share the same indexes.
Declare the partition column with #[NestedSetScope] and the package
constrains every internal write — gap-shifts, range queries, repair
walks — to that scope automatically:
use Vusys\NestedSet\Attributes\NestedSetScope;
use Vusys\NestedSet\Contracts\HasNestedSet;
use Vusys\NestedSet\NodeTrait;
#[NestedSetScope('menu_id')]
class MenuItem extends Model implements HasNestedSet
{
use NodeTrait;
protected $fillable = ['name', 'menu_id'];
}
Multi-column scopes work too: #[NestedSetScope(['tenant_id', 'menu_id'])].
For dynamic scopes that need runtime resolution, override
getScopeAttributes() instead — the attribute takes precedence when
both are present.
Picturing the table
A menu_items table for two menus might hold:
id menu_id name lft rgt parent_id
-- ------- ---------- --- --- ---------
1 1 Home 1 6 null ← menu 1's root
2 1 About 2 3 1
3 1 Contact 4 5 1
4 2 Dashboard 1 4 null ← menu 2's root
5 2 Profile 2 3 4
Both menus start their lft at 1 — they're independent trees,
partitioned by menu_id.
Reading
Reads compose with regular Eloquent — no special API:
$menu = Menu::find(1);
// All root items in this menu
MenuItem::query()->whereBelongsTo($menu)->whereIsRoot()->get();
// Descendants of a specific item, within its menu
MenuItem::query()
->whereBelongsTo($menu)
->whereDescendantOf($node->getBounds())
->get();
Writes are scope-checked
The trait refuses to move a node into a different scope:
$menu1Item->appendToNode($menu2Item);
// → Vusys\NestedSet\Exceptions\ScopeViolationException
This is intentional: silently rewriting menu_id on a moved subtree
is almost never what you want, and a wrong move is hard to detect
after the fact. If you genuinely need to migrate a subtree to a
different scope, do it explicitly with a fresh insert + delete.
Scoped repairs need an anchor
fixTree() and
fixAggregates() refuse to run
on a scoped model without an anchor node — any row that identifies
which menu you want to repair:
MenuItem::fixTree(); // → ScopeViolationException
MenuItem::fixTree($anyItem); // repairs $anyItem->menu_id only
That guard prevents a casual fixTree() call on a multi-million-row
forest from walking every tree to repair one.