Tree Queries

The model query builder (TreeQueryBuilder) adds tree-aware scopes that compose with regular Eloquent constraints. Every scope takes a NodeBounds value (returned by $node->getBounds()) so you can query by structure even when you don't have a hydrated model handy.

Example tree

The examples below assume this Category tree, where each row is labelled with its (lft, rgt) interval:

Electronics      (1, 12)
├── Computers    (2, 7)
│   ├── Laptops  (3, 4)
│   └── Desktops (5, 6)
└── Phones       (8, 11)
    └── Android  (9, 10)

Books            (13, 18)
├── Fiction      (14, 15)
└── Non-fiction  (16, 17)

Descendant / ancestor scopes

whereDescendantOf returns every node strictly below the given bounds; the *OrSelf variant includes the bounds row itself.

$electronics = Category::firstWhere('name', 'Electronics');

Category::query()
    ->whereDescendantOf($electronics->getBounds())
    ->pluck('name');
// → ['Computers', 'Laptops', 'Desktops', 'Phones', 'Android']

Category::query()
    ->whereDescendantOrSelf($electronics->getBounds())
    ->pluck('name');
// → ['Electronics', 'Computers', 'Laptops', 'Desktops', 'Phones', 'Android']

whereAncestorOf walks upward — every node strictly above the bounds. Useful for breadcrumbs.

$laptops = Category::firstWhere('name', 'Laptops');

Category::query()
    ->whereAncestorOf($laptops->getBounds())
    ->defaultOrder()
    ->pluck('name');
// → ['Electronics', 'Computers']

Category::query()
    ->whereAncestorOrSelf($laptops->getBounds())
    ->defaultOrder()
    ->pluck('name');
// → ['Electronics', 'Computers', 'Laptops']

ancestorsOf($bounds) and descendantsOf($bounds) are one-word aliases for whereAncestorOf / whereDescendantOf — exposed so call sites that read as English (Category::query()->ancestorsOf(...)) can avoid the where* prefix. Same behaviour, same arguments.

Roots, leaves, ordering

Category::query()->whereIsRoot()->pluck('name');
// → ['Electronics', 'Books']

Category::query()->whereIsLeaf()->pluck('name');
// → ['Laptops', 'Desktops', 'Android', 'Fiction', 'Non-fiction']

// leaves() is a one-word alias for whereIsLeaf().
Category::query()->leaves()->pluck('name');

// withoutRoot() excludes roots — the inverse of whereIsRoot().
Category::query()->withoutRoot()->pluck('name');
// → ['Computers', 'Laptops', 'Desktops', 'Phones', 'Android', 'Fiction', 'Non-fiction']

// One-shot first-root lookup — sugar for whereIsRoot()->first():
Category::query()->root();   // ?Category — first root by query order, or null if none

// Ordering by lft yields depth-first traversal order
Category::query()->defaultOrder()->pluck('name');
// → ['Electronics', 'Computers', 'Laptops', 'Desktops', 'Phones',
//    'Android', 'Books', 'Fiction', 'Non-fiction']

// reversed() orders by lft DESC — useful when you want bottom-up walks
Category::query()->reversed()->pluck('name');

// withDepth() selects the depth column under the alias 'depth'
Category::query()->withDepth()->get();

Positional scopes

whereIsBefore / whereIsAfter slice the tree at a node's bounds. Useful when you need "all rows preceding this one in depth-first order" — e.g. for next-prev navigation in a sequenced tree.

$phones = Category::firstWhere('name', 'Phones');

Category::query()
    ->whereIsBefore($phones->getBounds())
    ->defaultOrder()
    ->pluck('name');
// → ['Electronics', 'Computers', 'Laptops', 'Desktops']

Category::query()
    ->whereIsAfter($phones->getBounds())
    ->defaultOrder()
    ->pluck('name');
// → ['Books', 'Fiction', 'Non-fiction']

Composing with Eloquent

These scopes are regular query-builder constraints — combine them with where, whereBelongsTo, with(...), eager-load constraints, joins, ordering, anything Eloquent supports:

// "Active descendants of Electronics, eager-load children, paginate"
Category::query()
    ->whereDescendantOf($electronics->getBounds())
    ->where('active', true)
    ->with('children')
    ->defaultOrder()
    ->paginate(20);

Fresh aggregate reads

When a model declares aggregate columns (see Aggregates), the builder exposes withFreshAggregates() to re-compute them per outer row via a correlated subquery — useful for drift detection and as the authoritative read on hot paths where you don't trust stored values.

Category::query()
    ->withFreshAggregates()                              // all declared aggregates
    ->get();

Category::query()
    ->withFreshAggregates(['tickets_total'])             // narrow to one column
    ->get();

See Reading Aggregates for the full contract and Production Notes for routing these reads to a read replica.