0% прочитано

When Children Are Not Enough: Querying the Full Descendant Tree

If the business rule means a category and everything below it, a children relation is not enough. Direct children and all descendants are different concepts, and recursion needs an explicit stopping rule.

20 жовтня 2026 р. 10 хв читанняLaravel

Recently I ran into a common category-tree bug again: the code handled children correctly, but the business semantics required something different.

Loading only direct children was not enough. The entire branch below the category had to be included: children, grandchildren, and every deeper level.

On a small tree, the distinction is easy to miss. As long as the structure looks like parent -> child, the implementation appears correct. The bug only becomes visible when a third level is added.

If the rule says "this category and everything below it," a children relation describes the wrong boundary. The concept you need is descendants.

Direct Children and All Descendants Are Different Concepts

One tree, but two different result sets
parent  |  +-- child-a  |     |  |     +-- grandchild-a1  |     |  |     +-- grandchild-a2  |  +-- child-b        |        +-- grandchild-b1

For parent, direct children are only child-a and child-b.

All descendants include child-a, child-b, grandchild-a1, grandchild-a2, and grandchild-b1.

Technically this may look like a small query difference, but for business logic it is a different data set.

A Normal children Relation Solves Only One Level

A normal adjacency-list relation
public function children(): HasMany{    return $this->hasMany(        Category::class,        'parent_id',    );}

This relation is correct. The problem is not the relation itself.

The problem appears when code uses children in a place where the rule actually refers to the entire subtree.

At that point, an implementation detail silently changes the meaning of the business rule.

The Typical Bug Passes Tests with Only Two Levels

This test does not verify recursive semantics
$root = Category::factory()->create(); $child = Category::factory()->create([    'parent_id' => $root->id,]); $this->assertTrue(    $root->children->contains($child),);

If the test fixture contains only a root and one child, the bug remains invisible.

To verify a rule that means "everything below this category," at least a third level is required. I now deliberately add a grandchild to this kind of test.

A third level protects the required semantics
$root = Category::factory()->create(); $child = Category::factory()->create([    'parent_id' => $root->id,]); $grandchild = Category::factory()->create([    'parent_id' => $child->id,]); $ids = $service->descendantIds(    $root,); $this->assertEqualsCanonicalizing(    [

Recursive Eager Loading Can Be Enough for a Small Tree

The relation loads children at each deeper level
public function descendantsTree(): HasMany{    return $this        ->children()        ->with(            'descendantsTree',        );}

For a small tree and a simple use case, this approach may be completely sufficient.

It gives us a nested structure that can be traversed recursively in PHP.

But the cost matters: Eloquent will execute queries across tree levels. For a large or very deep structure, this may no longer be the right choice.

If I Only Need IDs, I Avoid Building More Structure Than Necessary

The recursion returns exactly what the use case needs
private function collectDescendantIds(    Category $category,): array {    $ids = [];     foreach (        $category->descendantsTree        as $child    ) {        $ids[] = $child->id;         array_push(            $ids,            ...$this->collectDescendantIds(                $child,            ),

If the next query only needs a set of category IDs, there is no need to pass an entire nested tree through the application code.

I prefer to separate two concerns: loading the tree and producing the exact result required by the use case.

The Parent Is Not Always Part of descendants

Another semantic detail is that descendants usually means everything below a node, not the root node itself.

If the business rule says "this category and all its descendants," the root must be included explicitly.

Root and descendants are clearer when expressed explicitly
$categoryIds = [    $category->id,    ...$service->descendantIds(        $category,    ),];

Where Recursion Should Stop

The most natural stopping point is a node with no children.

But in a real system I also check a few other constraints:

  • whether the business rule limits the depth;
  • whether traversal may cross category types or scopes;
  • whether the database guarantees there are no cycles;
  • whether the complete tree may become too large for one request.

Cycle Protection Matters When the Database Does Not Guarantee a Tree

This is a cycle, not a tree
A|vB|vC|+----> A

This structure is invalid for a true tree.

If the database or service layer does not guarantee an acyclic structure, recursive traversal should protect itself with visited IDs or an explicit depth limit.

Otherwise one broken relation can turn a simple query into infinite recursion.

For Larger Trees, Consider a Recursive CTE

One query can traverse the full subtree
WITH RECURSIVE descendants AS (    SELECT id, parent_id    FROM categories    WHERE parent_id = :root_id     UNION ALL     SELECT c.id, c.parent_id    FROM categories c    INNER JOIN descendants d        ON c.parent_id = d.id)SELECT idFROM descendants;

A recursive CTE is useful when the tree is deep, when only a flat descendant set is needed, or when PHP recursion would trigger too many queries.

But I do not switch to a CTE automatically. For a small tree, straightforward Eloquent code may be easier to understand and fast enough.

The Choice Depends on More Than Tree Size

I choose based on the use case:

  • for a UI tree, a nested structure is often useful;
  • for filtering, descendant IDs may be enough;
  • for authorization or visibility rules, exact root-plus-descendants semantics matter;
  • for large batch operations, loading full Eloquent models may be unnecessary.

Method Names Should Express the Depth Semantics

Different names for different contracts
children() descendants() descendantIds() selfAndDescendantIds()

After this kind of bugfix, I pay much more attention to naming.

A method called children() should not suddenly return grandchildren. A method called descendants() should not stop at the first level.

Precise naming reduces the chance that months later someone will use correct code with the wrong semantics.

My Minimum Test Set for a Tree

  • a node with no children returns an empty descendant set;
  • one child is returned;
  • a grandchild is returned too;
  • parallel branches are not lost;
  • the root is not included in descendants unless the contract says so;
  • self plus descendants includes the root exactly once;
  • a scope boundary is not crossed when the rule forbids it.

The Bug Was Not About Recursion, but About the Wrong Concept

The most useful lesson from this case is simple: first, name the required data set correctly.

If the rule is about direct children, one level is enough. If the rule is about the entire branch, descendants must be modeled explicitly.

Recursion is only an implementation technique. The real bug starts earlier, when children and descendants are treated as the same concept.

If the business rule says "everything below this category," I no longer look only at children. I verify that the code really traverses the full descendant tree.