Eloquent Relations

NodeTrait registers four relations on every node:

Relation Type Returns
parent BelongsTo The immediate parent, or null for a root
children HasMany Immediate children (one level down)
ancestors custom (eager-loadable) Every node above this one
descendants custom (eager-loadable) Every node below this one
$laptops->parent->name;             // 'Computers'
$computers->children->pluck('name'); // ['Laptops', 'Desktops']

$laptops->ancestors->pluck('name');
// → ['Electronics', 'Computers']   — ordered root-to-parent

$electronics->descendants->pluck('name');
// → ['Computers', 'Laptops', 'Desktops', 'Phones', 'Android']

1. Eager loading

The custom relations work with with(...) and load in two queries total — no N+1, regardless of how many rows you select:

Category::with('ancestors')->get();      // breadcrumbs for every row, 2 queries
Category::with('descendants')->get();    // subtree for every row, 2 queries

whereHas and withCount work too:

Category::whereHas('descendants', fn ($q) => $q->where('active', true))->get();
Category::withCount('descendants')->get();   // each row gets descendants_count

2. Bounding the descendants relation

The descendants relation is unbounded by default — it pulls every descendant of every selected row. For trees with deep, wide subtrees this can be a lot more data than the UI needs. Bound the load to the first N levels by composing a where on the relation's depth column (which the trait already maintains):

// Just children + grandchildren of $root (depth 1 + 2 relative to root)
$root->load([
    'descendants' => fn ($q) => $q->where('depth', '<=', $root->depth + 2),
]);

// Or on a top-level query — load every root with its first two levels
Category::with([
    'descendants' => fn ($q) => $q->where('depth', '<=', 2),
])->whereIsRoot()->get();

The bounded WHERE is served by the composite (scope, lft, rgt, parent_id, …cover) index on the lft/rgt range; the depth predicate is then a cheap filter over that already-narrow row set. (depth itself isn't in the index — add it to the migration's cover columns if you filter on depth heavily.)

Eager-loading descendants is also the recommended setup for walking a subtree — the walker reads the loaded relation directly without issuing its own query.

3. Combining with tree query scopes

Relations stack with the tree-query scopes freely. This pattern is common for category-tree pages: load every root with its first two levels, ordered for display:

$tree = Category::query()
    ->whereIsRoot()
    ->with(['descendants' => fn ($q) => $q->where('depth', '<=', 2)->defaultOrder()])
    ->defaultOrder()
    ->get();