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.
Direct Children and All Descendants Are Different Concepts
parent | +-- child-a | | | +-- grandchild-a1 | | | +-- grandchild-a2 | +-- child-b | +-- grandchild-b1For 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
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
$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.
$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
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
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.
$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
A|vB|vC|+----> AThis 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
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
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.



