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.

A Small Tree Makes the Problem Obvious
Imagine a three-level category hierarchy.
Development|+-- Backend| || +-- Laravel|+-- DevOpsWhen 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
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.
Before: Development|+-- Backend+-- DevOps Expected category scope: Development|+-- Backend| || +-- Laravel|+-- DevOpsAll 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.
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.
Expected: Backend Laravel SymfonyDevOps Docker Nginx Not an arbitrary flat sort: BackendDockerLaravelNginxSymfonyDevOpsI 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.
/* * 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.
Localization Should Not Change Tree Membership
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.
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.
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 Rootis 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.



