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\MaintainsTreeAggregates;
use Vusys\NestedSet\NodeTrait;
#[NestedSetScope('menu_id')]
class MenuItem extends Model implements MaintainsTreeAggregates
{
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.
1. Picturing the table
Each menu is a fully independent nested-set tree, starting its lft numbering at 1. They share the table but never overlap because every internal query filters by menu_id first.
Menu 1 (menu_id = 1):
Home {menu_id=1}
About {menu_id=1}
Contact {menu_id=1}
Menu 2 (menu_id = 2):
Dashboard {menu_id=2}
Profile {menu_id=2}
The lft / rgt badges on each row show that both trees number their slots from 1 — that's the whole point of the scope: every tree gets its own slot space within the shared table.
Because both trees restart at 1, their bounds overlap (Home.lft = 1 = Dashboard.lft). whereDescendantOf($home) adds only the bounds predicates (home.lft < lft AND rgt < home.rgt) — it does not add the scope column on its own. Add the scope predicate yourself (whereBelongsTo($menu) or where('menu_id', 1)) so the query stays inside one tree; every read below does. (The model-instance predicates isDescendantOf() / isAncestorOf() do compare scope, so those are safe without the extra clause.)
2. 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();
3. 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.
4. 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.