0% прочитано

Laravel Category Trees: Why a Recursive Feed Showed Posts from All Descendants but Navigation Only Direct Children

On an ICanUp category page, the recursive feed already included Posts from every public descendant category, while the category navigation showed only immediate children. A visitor could therefore see a Post from a deeply nested category without seeing a link to that category. The real issue was not recursion itself but two different tree contracts on the same public page.

15 вересня 2026 р. 9 хв читанняLaravel

On a public Category page in ICanUp, I noticed a subtle inconsistency.

The Post feed was already recursive: a parent category page could contain Posts not only from the current category but also from public descendants several levels deep.

The category navigation, however, exposed only immediate first-level children.

A visitor could therefore see a Post from a deeply nested category without seeing that category among the available navigation links.

This was not simply a broken recursive relationship. The feed and category navigation were each locally reasonable, but they used different tree scopes on the same page.
Laravel category tree where a recursive feed includes posts from deep descendants while navigation exposes only immediate children
The feed sees the full public subtree while navigation sees only the first level. The scope mismatch creates the inconsistency.

A Small Tree Makes the Problem Obvious

Imagine a three-level category hierarchy.

Simplified category tree
Development|+-- Backend|   ||   +-- Laravel|+-- DevOps

When opening Development, the recursive feed can correctly include a Post from Laravel.

Direct-children navigation, however, shows only Backend and DevOps.

The visitor never sees the Laravel category itself even though its content is already present in the feed.

There Were Two Different Tree Contracts

The problem became clearer when I stopped treating the page as one category query.

It actually contains two separate contracts.

Contract

Scope

Post feed

Current category + all available public descendants

Category navigation

Only direct public children

Either contract can be correct in the right interface.

A menu, for example, can intentionally show only one level of a hierarchy.

On this page, however, the category links also explain which categories make up the recursive content scope. That is where the mismatch became a problem.

I Did Not Want to Change the Recursive Feed

The easiest way to make both blocks match could have been the wrong solution: restrict the feed to direct children as well.

But the recursive feed already represented the intended product behavior.

A broad category page should collect relevant content from its public subtree rather than forcing a visitor to open every hierarchy level separately.

The category navigation contract is therefore the part that needs to match the existing content scope.

The Rule Is Simple: If Content Scope Is Recursive, Category Scope Should Explain It

One descendant scope
Current category      |      +-----------------------+      |                       |      v                       vPost feed               Category list      |                       |      v                       vpublic descendants      public descendants Same subtree.Different presentation.

This does not mean the feed and navigation need identical SQL or identical DTOs.

They have different presentation responsibilities.

But they should share the same tree boundary: both operate inside the current category subtree.

Direct Children Still Remain Descendants

Moving from direct children to all descendants does not remove the first level.

Immediate children remain part of the result, with deeper public categories added after them according to the tree contract.

Navigation covers the public subtree
Before: Development|+-- Backend+-- DevOps  Expected category scope: Development|+-- Backend|   ||   +-- Laravel|+-- DevOps

All Descendants Does Not Mean Every Category in the Database

The word "recursive" can easily lead to a query that is broader than intended.

Only descendants of the current category branch belong here.

A sibling branch or unrelated root category must not appear merely because it is public too.

Include

Exclude

Direct public children

Categories from another branch

Public grandchildren

Inactive categories

Deeper public descendants

Hidden categories

Descendants in stable tree order

Deleted categories

Public Visibility Is Part of the Tree Contract

Simply obtaining all descendant IDs is not enough.

A public Category page should only expose categories that are allowed to be visible publicly.

An inactive, hidden, or deleted node should not suddenly appear just because the recursive tree query can technically reach it.

Tree membership and public visibility are separate conditions. A category can belong to the subtree without being eligible for public navigation.

Duplicates Would Be a Contract Bug Too

If descendant scope is constructed in several places or merged with direct children separately, it is easy to return one category more than once.

The result therefore needs to be recursive and deterministic: one category, one position, and stable ordering.

Tree Order Should Not Be Replaced by an Arbitrary Name Sort

Ordering is another important detail.

A category tree already has structural ordering. Sorting all descendants by name or id after the query can destroy the meaning of that hierarchy.

The recursive scope should preserve the domain tree and order contract.

Tree order and arbitrary flat order mean different things
Expected: Backend  Laravel  SymfonyDevOps  Docker  Nginx  Not an arbitrary flat sort: BackendDockerLaravelNginxSymfonyDevOps

I Do Not Need a Second Hand-Written Recursion

There is another architectural point I want to preserve.

If the category domain already knows how to resolve descendants, the public feed and navigation should not each invent independent recursion.

Otherwise one implementation will eventually treat visibility, depth, or ordering differently from the other.

PHP - Conceptual shared descendant scope
/* * Conceptual contract, not the project API. */ $publicDescendantIds = $treeScope    ->for($currentCategory)    ->publicDescendants(); $feedCategoryIds = [    $currentCategory->id,    ...$publicDescendantIds,]; $navigationCategoryIds = [    ...$publicDescendantIds,];

The method name is not the important part here. The dependency direction is: descendant scope is defined as one domain rule, while the feed and navigation consume that scope for different presentation tasks.

In ICanUp, UK and EN use the same category entities and the same shared-slug architecture.

Localization determines the public URL and category-link text, but it should not change the tree itself.

The same descendant remains a descendant regardless of locale.

Localization on top of one category tree
UK: /categories/laravel EN: /en/categories/laravel  Same category node.Same subtree membership.Localized public URL.

This Is Not a Slug or SEO Architecture Change

Fixing the tree scope does not require a new slug contract, canonical changes, or a hreflang redesign.

Category links should simply continue using the existing localized routing layer.

This is an important scope boundary: a small navigation bug should not turn into a rewrite of URL architecture.

The Best Regression Test Needs at Least Three Levels

A test containing only a parent and child cannot reproduce this bug.

At one nested level, direct children and recursive descendants effectively contain the same nodes.

The mismatch only becomes visible once a grandchild exists.

Minimum tree for the regression test
Root|+-- Child A|   ||   +-- Grandchild A1|       ||       +-- Post X|+-- Child B Other Root|+-- Unrelated Category
  • The Root page includes Child A.
  • The Root page includes Grandchild A1.
  • The recursive Root feed contains Post X.
  • An inactive descendant is excluded.
  • A hidden or deleted descendant is excluded.
  • A category under Other Root is excluded.
  • No category is duplicated.
  • Ordering follows the tree contract.
  • UK and EN links resolve to the correct localized routes.
  • The existing recursive Post feed does not regress.

Responsive UI Matters Even Though the Bug Starts in Data Scope

Moving from one hierarchy level to all descendants naturally increases the possible number of category links.

A backend fix therefore should not be verified only through response data.

Category chips or links still need to wrap and remain usable on desktop and mobile without overflowing their container.

When Direct Children Would Actually Be Correct

I do not consider direct-children navigation inherently wrong.

In a hierarchical menu it can be exactly the correct behavior.

The difference is the semantics of the UI.

UI

Logical scope

Tree navigation menu

Direct children can be correct

Breadcrumbs

Ancestors of the current node

Recursive category feed

Current node + descendants

Category filters explaining the recursive feed

The same public descendants that form the content scope

The lesson is therefore not "always show every descendant".

The lesson is to define the semantic contract of every tree representation first.

What I Would Avoid

  • Do not make the recursive Post feed direct-only just to match navigation.
  • Do not query every public category without restricting the current subtree.
  • Do not expose inactive, hidden, or deleted descendants.
  • Do not merge direct children and descendants without deduplication.
  • Do not apply arbitrary sorting that destroys hierarchy and domain order.
  • Do not write separate recursive logic in every query when the domain already has a tree abstraction.
  • Do not mix this fix with changes to the shared-slug architecture.
  • Do not test recursive behavior using only two hierarchy levels.

In a data tree, a locally correct query does not guarantee a correct interface. Multiple blocks on the same page still need to agree on which part of the tree they represent.

What I Kept for Next Time

  • Explicitly define whether a tree-based UI needs the current node, ancestors, children, or descendants.
  • Do not treat direct children and recursive descendants as interchangeable concepts.
  • When navigation explains recursive content scope, use the same subtree boundary.
  • Apply public visibility separately from tree membership.
  • Never allow a category from another branch into a descendant result.
  • Preserve domain tree order.
  • Build recursive regression tests with at least three hierarchy levels.
  • Apply localization to URLs and presentation, not to tree membership.
  • Verify responsive UI after increasing the number of descendant links.

Conclusion

In this case, the recursive feed in ICanUp was doing exactly what it was designed to do: collecting Posts from the entire public category subtree.

The inconsistency existed next to it. Navigation showed only direct children and therefore did not represent the full content scope already visible lower on the page.

The correct direction is not to remove recursion from the feed, but to give both blocks the same tree boundary: the current category subtree with public descendants, stable ordering, and no categories from unrelated branches.

The interesting part is that both queries could be locally correct. The bug appeared because their contracts did not agree at the page level.