0% прочитано

Laravel Category Trees: чому recursive feed показував пости всіх descendants, а навігація лише direct children

На сторінці категорії ICanUp recursive feed уже показував Post із усіх вкладених public categories, але список категорій містив тільки безпосередніх дітей. У результаті користувач міг бачити Post із глибоко вкладеної категорії, але не бачив посилання на саму категорію. Це виявило не проблему рекурсії як такої, а два різні tree contracts для одного public page.

15 вересня 2026 р. 9 хв читанняЛаравель

На public Category page у ICanUp я помітила неочевидну невідповідність.

Список Post уже формувався рекурсивно: сторінка батьківської категорії могла показувати Post не тільки з неї самої, а й із вкладених public categories на кількох рівнях.

Але блок навігації категоріями показував лише безпосередніх дітей першого рівня.

У результаті користувач міг бачити Post із глибоко вкладеної категорії, але не бачити саму цю категорію серед доступних посилань.

Це не був звичайний баг recursive relationship. Feed і category navigation окремо поводилися логічно, але використовували різні tree scopes на одній сторінці.
Laravel category tree where a recursive feed includes posts from deep descendants while navigation exposes only immediate children
Feed бачить усе public subtree, а navigation лише перший рівень. Саме різниця scope створює невідповідність.

Простий приклад дерева показав проблему

Уявімо три рівні категорій.

Спрощене дерево категорій
Development|+-- Backend|   ||   +-- Laravel|+-- DevOps

Якщо відкрити Development, recursive feed може коректно включати Post із Laravel.

Але direct-children navigation покаже тільки Backend і DevOps.

Самої категорії Laravel користувач на цій сторінці не побачить, хоча її контент уже є у feed.

Тут існували два різні tree contracts

Проблема стала зрозумілішою, коли я перестала дивитися на це як на один category query.

На сторінці насправді працюють два різні контракти.

Контракт

Scope

Post feed

Поточна category + усі доступні public descendants

Category navigation

Лише direct public children

Кожен із цих контрактів може бути правильним у певному інтерфейсі.

Наприклад, меню часто навмисно показує тільки один рівень дерева.

Але на цій конкретній сторінці navigation одночасно пояснює, з яких категорій складається recursive content scope. Саме тому різні scopes стали проблемою.

Recursive feed я не хотіла змінювати

Найпростіший спосіб зробити обидва блоки однаковими міг би бути неправильним: обмежити feed тільки direct children.

Але recursive feed уже мав потрібну продуктову поведінку.

Сторінка великої категорії повинна збирати релевантний контент із її public subtree, а не змушувати користувача відкривати кожен рівень окремо.

Тому змінювати потрібно не scope контенту, а category navigation contract.

Правило стало простим: якщо content scope recursive, category scope теж має його пояснювати

Єдиний descendant scope
Current category      |      +-----------------------+      |                       |      v                       vPost feed               Category list      |                       |      v                       vpublic descendants      public descendants Same subtree.Different presentation.

Це не означає, що feed і navigation повинні використовувати однаковий SQL або повертати однакові DTO.

Вони вирішують різні presentation задачі.

Але boundary дерева має бути спільною: обидва блоки працюють у межах одного current category subtree.

Direct children все одно залишаються descendants

Перехід від direct children до all descendants не означає, що перший рівень зникає.

Безпосередні дочірні categories залишаються у результаті, просто до них додаються доступні public categories із глибших рівнів.

Navigation охоплює все public subtree
Before: Development|+-- Backend+-- DevOps  Expected category scope: Development|+-- Backend|   ||   +-- Laravel|+-- DevOps

Але all descendants не означає всі categories у базі

Після слова "recursive" легко випадково розширити query надто далеко.

Потрібні лише descendants поточної category branch.

Sibling branch або зовсім інша root category не повинні з'явитися в navigation лише тому, що вони теж public.

Включати

Не включати

Direct public children

Categories з іншої branch

Public grandchildren

Inactive categories

Глибші public descendants

Hidden categories

Descendants у стабільному tree order

Deleted categories

Public visibility є частиною tree contract

Мені недостатньо просто отримати всі descendant IDs.

Public Category page повинна працювати тільки з categories, які дозволено показувати користувачу.

Inactive, hidden або deleted node не повинна раптом з'явитися лише через те, що recursive tree query її технічно бачить.

Tree membership і public visibility - різні умови. Category може належати subtree, але це ще не означає, що її можна показати на public page.

Дублікати тут теж були б помилкою контракту

Якщо descendant scope будується в кількох місцях або об'єднується з direct children окремо, легко отримати одну category двічі.

Тому результат має бути не просто recursive, а deterministic: одна category, одна позиція, стабільний порядок.

Порядок дерева не можна замінити випадковим order by name

Ще одна деталь - порядок.

Category tree має власну структуру і порядок вузлів. Якщо descendants після query просто відсортувати за назвою або id, navigation може перестати відповідати структурі дерева.

Recursive scope повинен зберігати tree/order contract домену.

Tree order і випадковий flat order мають різний зміст
Expected: Backend  Laravel  SymfonyDevOps  Docker  Nginx  Not an arbitrary flat sort: BackendDockerLaravelNginxSymfonyDevOps

Не обов’язково писати другу рекурсію вручну

Для мене важливий ще один архітектурний принцип.

Якщо category domain уже вміє визначати descendants, public feed і navigation не повинні кожен окремо винаходити власну рекурсію.

Інакше з часом одна реалізація почне враховувати visibility, depth або ordering інакше за іншу.

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

Тут важлива не назва методу, а сам напрямок залежності: descendant scope визначається один раз як domain rule, а feed і navigation використовують його для своїх задач.

У ICanUp UK і EN використовують ті самі category entities та shared slug architecture.

Тому localization відповідає за правильний public URL і текст category link, але не повинна змінювати саме дерево.

Один і той самий descendant має залишатися descendant незалежно від locale.

Localization поверх одного category tree
UK: /categories/laravel EN: /en/categories/laravel  Same category node.Same subtree membership.Localized public URL.

Це не завдання на зміну slug або SEO architecture

Виправлення tree scope не потребує нового slug contract, зміни canonical або перебудови hreflang.

Category links повинні просто використовувати вже існуючий localized routing layer.

Це важлива межа scope: не потрібно перетворювати невеликий navigation bug на переписування URL architecture.

Найкращий regression test потребує щонайменше трьох рівнів

Якщо тест створює тільки parent і child, він не відтворює цей bug.

На одному рівні direct children і recursive descendants фактично дають той самий набір.

Проблема стає видимою лише коли з'являється хоча б grandchild.

Мінімальне дерево для regression test
Root|+-- Child A|   ||   +-- Grandchild A1|       ||       +-- Post X|+-- Child B Other Root|+-- Unrelated Category
  • Root page показує Child A.
  • Root page показує Grandchild A1.
  • Recursive feed Root містить Post X.
  • Inactive descendant не показується.
  • Hidden або deleted descendant не показується.
  • Category з Other Root не потрапляє в результат.
  • Одна category не дублюється.
  • Порядок відповідає tree contract.
  • UK і EN links ведуть на правильні localized routes.
  • Поточний recursive Post feed не регресує.

Mobile UI теж важливий, хоча bug був на рівні даних

Після переходу від одного рівня до всіх descendants кількість category links природно може зрости.

Тому backend fix недостатньо перевірити лише тестом response data.

Chips або category links мають залишатися придатними для використання на desktop і mobile без виходу за межі контейнера чи поламаного wrapping.

Коли direct children справді були б правильними

Я не вважаю direct-children navigation неправильною сама по собі.

У hierarchical menu вона часто є саме тим, що потрібно.

Різниця в семантиці сторінки.

UI

Логічний scope

Tree navigation menu

Direct children можуть бути правильними

Breadcrumbs

Ancestors поточного node

Recursive category feed

Current node + descendants

Category filters, що пояснюють recursive feed

Ті самі public descendants, що формують content scope

Тому головний урок цього кейсу не "завжди показувати всі descendants".

Головний урок - спочатку визначити семантичний contract кожного представлення дерева.

Що я б не робила

  • Не змінювала б recursive Post feed на direct-only лише для узгодження з navigation.
  • Не отримувала б усі public categories без обмеження current subtree.
  • Не включала б inactive, hidden або deleted descendants.
  • Не об’єднувала б direct children і descendants без deduplication.
  • Не сортувала б tree result випадковим способом, що руйнує hierarchy/order.
  • Не писала б окрему recursive logic у кожному query, якщо domain уже має tree abstraction.
  • Не змішувала б цей fix зі зміною shared slug architecture.
  • Не перевіряла б regression лише на двох рівнях hierarchy.

У дереві даних правильний query ще не гарантує правильний інтерфейс. Кілька блоків однієї сторінки повинні домовитися, яку частину дерева вони представляють.

Що я залишила собі на майбутнє

  • Для кожного tree-based UI явно визначати current node, ancestors, children або descendants.
  • Не вважати direct children і recursive descendants взаємозамінними поняттями.
  • Якщо navigation пояснює recursive content scope, використовувати спільну subtree boundary.
  • Public visibility застосовувати окремо від tree membership.
  • Не дозволяти category з іншої branch потрапити в descendant result.
  • Зберігати domain tree order.
  • Regression test для recursive behavior будувати мінімум на трьох рівнях.
  • Localization застосовувати до URL і presentation, а не до membership дерева.
  • Перевіряти responsive UI після збільшення кількості descendant links.

Висновок

У цьому кейсі recursive feed у ICanUp робив саме те, що від нього очікувалося: збирав Post із усього public category subtree.

Невідповідність виникла поруч: navigation показувала лише direct children і тому не відображала повний scope контенту, який користувач уже бачив нижче.

Правильний напрямок для fix - не прибирати рекурсію з feed, а дати обом блокам спільну boundary дерева: current category subtree з public descendants, стабільним порядком і без categories з інших branches.

Найцікавіше тут те, що обидва queries могли бути локально правильними. Bug з'явився через те, що їхні contracts не збігалися на рівні всієї сторінки.