Walking Subtrees
The walker is an in-memory traversal helper. You hand it a loaded subtree, you get a generator that yields nodes in DFS pre-order, DFS post-order, or BFS — plus a visitor-form walk() that exposes depth, parent, and sibling info per visit.
It is purely a consumer of data you have already loaded. The walker never queries the database.
1. What you get
// Pre-order DFS — root first, then each subtree left-to-right.
foreach ($electronics->dfs() as $node) {
echo $node->name, "\n";
}
// Visitor form with per-visit context.
$electronics->walk(function (Model $node, WalkContext $ctx) {
echo str_repeat(' ', $ctx->depth), $node->name, "\n";
});
// One-liner for "give me the subtree as a Collection in pre-order".
$ordered = $electronics->flattenedSubtree();
Five public methods land on every NodeTrait model:
| Method | Returns | Order |
|---|---|---|
dfs($subtree?, $filter?) |
Generator<Model> |
DFS pre-order |
dfsPostOrder($subtree?, $filter?) |
Generator<Model> |
DFS post-order |
bfs($subtree?, $filter?) |
Generator<Model> |
Breadth-first |
walk($visitor, $strategy?, $subtree?, $filter?) |
void |
Visitor-driven; strategy = 'pre', 'post', or 'bfs' |
flattenedSubtree($strategy?, $subtree?, $filter?) |
EloquentCollection |
Strategy of your choice |
2. The loaded-subtree contract
Note
The walker never queries the database. Eager-load descendants (via ->load('descendants') or Category::with('descendants')) or pass a collection explicitly via the $subtree argument. If you have narrowed the subtree on purpose — a filtered eager-load, a depth-limited slice, an ad-hoc collection — the walker honours that scope rather than silently widening it.
When you omit $subtree, the walker reads $this->descendants. If the relation is not loaded and you pass no collection, it throws UnloadedSubtreeException:
$electronics->walk($visitor);
// UnloadedSubtreeException: the walker is in-memory only — call
// ->load('descendants') first or pass a collection explicitly
// ($subtree argument). The walker does not query the database.
Three load patterns cover almost every case:
// 1. Eager-load up front (one query).
$root = Category::with('descendants')->find($id);
$root->walk($visitor);
// 2. Lazy-load on demand (one extra query).
$root = Category::find($id);
$root->load('descendants');
$root->walk($visitor);
// 3. Explicit, scoped load — the walker honours whatever slice you supply.
$slice = $root->descendants()->where('depth', '<=', 2)->get();
$root->walk($visitor, subtree: $slice);
The explicit form is the right tool when you want to walk a partial subtree — the walker treats nodes whose children are missing from the slice as leaves and keeps going.
3. Strategies side by side
For the tree
Electronics
Laptops
Phones
iPhone
Android
each strategy yields a different order:
| Strategy | Visit order |
|---|---|
'pre' (DFS pre-order) |
Electronics, Laptops, Phones, iPhone, Android |
'post' (DFS post-order) |
Laptops, iPhone, Android, Phones, Electronics |
'bfs' (Breadth-first) |
Electronics, Laptops, Phones, iPhone, Android |
(The pre-order and BFS orders happen to match on this shallow tree because every parent has its children grouped under it. On deeper trees they diverge.) The lft / rgt badges on each row give you the underlying nested-set bounds the walker uses internally — DFS pre-order is the same order as iterating by lft ascending, which is why an already-loaded descendants relation walks for free.
Pre-order is the default everywhere — it's what humans expect, and what every other tree-printing utility in this package uses.
4. The visitor signature
walk() calls your closure as
function (Model $node, WalkContext $ctx): WalkSignal|null
WalkContext carries everything the walker knows about the current visit:
| Field | Type | What it means |
|---|---|---|
depth |
int |
Relative to the walk root, not the absolute depth column on the node. The root itself is depth 0. |
parent |
?Model |
The hydrated parent from the same subtree, or null at the walk root. |
siblingIndex |
int |
0-indexed position among siblings under the same parent in the loaded subtree. |
siblingCount |
int |
Total number of siblings under the same parent. |
isFirstSibling |
bool |
Derived; both flags are true for an only child. |
isLastSibling |
bool |
Same. |
pathToRoot() |
list<Model> |
Ancestor chain from the current node up to (but excluding) the walk root. Empty at the root. Computed on first call. |
The absolute depth column on the node is unchanged — read it via $node->getDepth() if you need it.
The ASCII-tree branch-character problem is the canonical motivating example:
$electronics->walk(function (Model $node, WalkContext $ctx) {
$branch = match (true) {
$ctx->depth === 0 => '',
$ctx->isLastSibling => '└── ',
default => '├── ',
};
echo $branch, $node->name, "\n";
});
5. Signals
Return a WalkSignal from the visitor to steer the walk:
| Signal | What it does |
|---|---|
WalkSignal::SkipSubtree |
Don't descend into this node's children. Honoured by pre-order DFS and BFS; ignored by post-order (children are already visited by the time the parent fires). |
WalkSignal::Stop |
Halt the walk immediately. No further visitors are called. |
null (or no return) |
Continue normally. |
$electronics->walk(function (Model $node, WalkContext $ctx) {
if ($node->name === 'Phones') {
return WalkSignal::SkipSubtree;
}
if ($node->discontinued) {
return WalkSignal::Stop;
}
});
6. Pruning with WalkFilter
WalkFilter is the static-pruning counterpart to the visitor's runtime signals. Use it when the decision about whether to visit a node doesn't depend on what the visitor does — depth ceilings, predicates, "skip everything trashed" rules.
use Vusys\NestedSet\Walker\WalkFilter;
// Root + two levels.
$electronics->walk($visitor, filter: WalkFilter::depth(2));
// Only nodes the predicate accepts. Pruned nodes' subtrees are
// also skipped — there is no way to skip one node but visit its
// children.
$electronics->walk($visitor, filter: WalkFilter::where(
fn (Model $n, WalkContext $ctx) => $n->active,
));
// AND two filters together: stricter depth wins; predicates compose.
$filter = WalkFilter::compose(
WalkFilter::depth(3),
WalkFilter::where(fn (Model $n, WalkContext $ctx) => $n->published),
);
// The instance-form `andThen()` reads better in pipelines.
$filter = WalkFilter::depth(3)->andThen(WalkFilter::where($predicate));
The full shape of a filter:
| Field | Type | Notes |
|---|---|---|
maxDepth |
?int |
null = unlimited. Inclusive — depth(2) allows depths 0, 1, 2. Relative to the walk root. |
visitable |
?Closure(Model, WalkContext): bool |
null = visit everything. Returning false skips the node and its subtree. |
includeRoot |
bool |
Default true. false visits descendants only; depth numbering still treats the (skipped) root as depth 0. |
Every walker method — dfs(), dfsPostOrder(), bfs(), walk(), flattenedSubtree() — accepts a WalkFilter as its last argument.
Tip
The walker's pruning is the same primitive the exporters use internally. If you find yourself reaching for AsciiOptions::maxDepth, the underlying WalkFilter is what's doing the work — and you can use it directly with walk(). All four exporter option objects (AsciiOptions, MermaidOptions, DotOptions, JsonOptions) now carry a ?WalkFilter $filter field so you can apply the same predicate to a Mermaid diagram, an ASCII tree, and a JSON export.
7. Generators vs walk()
dfs(), dfsPostOrder(), bfs() return PHP generators. walk() takes a callback. Both shapes are useful:
Tip
Use the generators (dfs() and friends) when you want to compose with PHP's iterator pipeline — foreach + break, iterator_to_array, custom map/filter. Use walk() when you're doing a side-effecting pass per node and want the per-visit WalkContext.
// Generator — break early.
foreach ($electronics->dfs() as $node) {
if ($node->matches($query)) {
return $node;
}
}
// Visitor — context-rich rendering.
$lines = [];
$electronics->walk(function (Model $n, WalkContext $ctx) use (&$lines) {
$lines[] = str_repeat(' ', $ctx->depth) . $n->name;
});
The generators do not support WalkSignal::SkipSubtree — they iterate every node the filter admits and let you break or continue externally. If you need dynamic per-node descent control, use walk().
8. flattenedSubtree()
A convenience for "give me an ordered Collection" — composition of walk() + collect:
$ordered = $electronics->flattenedSubtree('pre');
$ordered = $electronics->flattenedSubtree('bfs', filter: WalkFilter::depth(2));
The difference between flattenedSubtree('pre') and descendants()->defaultOrder()->get() is where the work happens:
flattenedSubtreereads the already-loaded collection and reorders in PHP. No SQL.defaultOrder()->get()issues a freshORDER BY lftquery against the database.
If you have already loaded the subtree, flattenedSubtree is free. If you haven't, do the query.
9. Pitfalls
Caution
Mutating parent_id mid-walk produces undefined visit order. The walker's parent → children index is built once at the start; changing the tree structure during traversal can cause nodes to be visited twice, skipped, or appear under the wrong parent. If you need to restructure during traversal, collect the targets in the visitor and apply mutations after the walk finishes.
Other rough edges to know about:
9.1 Orphans
Nodes in the loaded collection whose parent_id doesn't match any other loaded row (and isn't the walk root) are unreachable from the root and silently skipped. Walk by reachability, not by raw membership.
9.2 Partial loads are honoured
If you load only depth 0 + 1, the walker treats the depth-1 nodes as leaves. It will not fetch their children for you.
9.3 Attribute mutations are fine
Mutating non-structural attributes (e.g. $node->name = 'new') inside a visitor is safe — the walker doesn't observe attribute changes. The structural index is only sensitive to parent_id.
10. Comparison with toTree()
NodeCollection::toTree() (see In-memory Tree Shaping) and the walker share the same input shape (a flat collection) but answer different questions.
| Shape | Returns | Best for |
|---|---|---|
$collection->toTree() |
Root model(s) with nested children arrays |
Blade recursive includes, JSON nested rendering, anything that wants the data structure |
$root->flattenedSubtree() |
Collection in chosen order | "Give me an ordered list", table rendering |
$root->walk($visitor) / dfs() |
Visit each node in order; no return | Side-effecting passes, search, validation |
Choose the walker when you only need to visit each node. Choose toTree() when you need the nested arrays to hand to a template.
11. UnloadedSubtreeException
Thrown by walk() / dfs() / dfsPostOrder() / bfs() / flattenedSubtree() when no explicit $subtree is supplied and the descendants relation is not loaded. Resolution: eager-load with ->load('descendants') or pass a collection explicitly. The walker never issues its own queries — this exception forces the load decision to be visible at the call site so a deliberately narrowed subtree is never accidentally widened.