0% прочитано

Laravel Search: як я об’єднала Posts, Categories, News і Tips в один публічний пошук без витоку Draft

У ICanUp один публічний пошук працює з Posts, Categories, News і Tips, але ці сутності мають різні правила видимості та різні поля для пошуку. Розбираю, чому пошук не повинен самостійно дублювати правила Draft, scheduled content, locale та ContentBlocks, як уникнути витоку непублічного контенту і чому UK та EN потрібно шукати окремо.

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

Коли в ICanUp з’явилися не тільки Posts, а й Categories, News та Tips, окремий пошук для кожного типу контенту вже не мав сенсу.

Користувач вводить один запит і очікує знайти все релевантне. Але для серверної частини це не означає просто виконати однаковий LIKE по чотирьох таблицях і об’єднати результати.

Кожна сутність має власні правила публічності, локалізації та набору полів, у яких взагалі дозволено шукати.

Найнебезпечніша помилка тут не в тому, що Search пропустить результат. Значно гірше, якщо він покаже Draft, запланований матеріал або текст іншої локалі, який публічна сторінка ще не повинна показувати.

Публічний пошук є ще одним публічним входом у контент. Для нього діють ті самі правила видимості, що й для detail page, feed, category page або іншого публічного списку.
Laravel public search architecture with Posts Categories News and Tips passing through separate visibility and locale rules before joining one search result stream
Один Search UI може показувати кілька типів сутностей, але кожна з них спочатку проходить власний контракт публічної видимості та локалі.

Laravel Search по кількох моделях: одна форма пошуку не означає одне правило

На рівні інтерфейсу все просто:

Один запит, кілька типів контенту
Search query    |    +--> Posts    +--> Categories    +--> News    +--> Tips    |    vone public result page

Але серверний контракт для кожної гілки різний.

Сутність

Основне правило публічності

Post

Published і published_at уже настав і є поточна локаль

Category

Category активна і є поточна локаль

News

Published, published_at уже настав і є поточна локаль

Tip

Published, published_at уже настав і є поточна локаль

Тому я не будую один великий універсальний запит, який намагається однаково трактувати всі сутності.

Спільним є Search orchestration. Правила доступності залишаються всередині відповідного контексту.

Як Draft-контент може витекти через Laravel Search

Уявімо, що звичайна сторінка Post коректно перевіряє статус і дату публікації.

А Search написаний окремо й шукає тільки за перекладом:

PHP - Пошук без контракту публічної видимості
Post::query()    ->whereHas(        'translations',        fn ($query) => $query            ->where('locale', $locale)            ->where('name', 'title')            ->where('value', 'like', '%'.$term.'%')    )    ->get();

Якщо тут немає правил публічності, Draft може з’явитися в Search навіть тоді, коли прямий URL правильно повертає 404.

Для користувача це виглядає як витік непублічного контенту. Для архітектури це означає, що система має дві різні відповіді на питання "чи доступний цей Post публічно?".

Search не повинен винаходити власну версію публічності сутності.

Posts: published_at і status важливіші за Category

Для Posts контракт пошуку навмисно відокремлений від видимості Category.

Post можна знайти, якщо:

  • status = published;
  • published_at не порожній;
  • published_at <= now().

При цьому Category не повинна випадково приховувати сам Post із пошуку.

Опублікований Post може бути без Category, Category може бути неактивною або мати show_in_public_feed = false. Якщо сам Post публічний, ці властивості Category не змінюють його Search contract.

Laravel Search для Posts: title, short_text і публічні ContentBlocks

Для Post я шукаю тільки в тексті, який має реальний зміст для читача:

  • title поточної локалі;
  • short_text поточної локалі;
  • публічний текст ContentBlocks поточної локалі.

Я свідомо не використовую для цього:

  • slug;
  • поля Category;
  • meta_title;
  • meta_description;
  • meta_keywords;
  • переклади іншої локалі.

Чому slug і SEO meta fields не повинні створювати Search results

Пошук по slug або SEO-полях спочатку здається способом "знайти більше".

Але це може створювати результати, причину яких користувач взагалі не бачить на сторінці.

Невидиме поле створює незрозумілий результат
User searches:deployment Visible article text:does not contain deployment Only meta_keywords:deployment

Для внутрішнього пошуку я хочу інший принцип: результат має знаходитися за текстом, який користувач справді може прочитати, або за явно визначеним змістовним зв’язком.

Categories мають інший public search contract

Category не має published_at і не повинна успадковувати правила Post.

Для Search достатньо, щоб Category була активною:

Публічність Category визначається її власним станом
Category status = true        |        vmay be searchable

show_in_public_feed тут не є умовою.

Так само Category не зникає з пошуку лише тому, що зараз не має опублікованих Posts.

Laravel Category Search не повинен залежати від feed visibility

Це хороший приклад того, чому не можна механічно повторно використовувати будь-який existing scope лише через слово "public".

Видимість у blog feed і можливість знайти активну Category через Search відповідають на різні питання.

Питання

Поле

Чи активна Category?

status

Чи показувати її у blog feed?

show_in_public_feed

Чи може Search знайти активну Category?

Не залежить від feed flag

Categories шукаються тільки за видимим локалізованим текстом

  • name;
  • short_text;
  • description.

І знову без slug та SEO-полів.

News: Draft і scheduled content не повинні потрапляти в Search

Для News повертаються тільки матеріали, які вже доступні публічно.

Контракт пошуку News
status = published AND published_at IS NOT NULL AND published_at <= now() AND current locale is available

Це захищає два різні сценарії.

Draft не повинен з’являтися ніколи. Scheduled News не повинна ставати видимою раніше published_at, навіть якщо її переклад уже існує в базі.

News шукається за title, excerpt і public ContentBlocks

  • title поточної локалі;
  • excerpt поточної локалі;
  • публічні ContentBlocks поточної локалі.

Search не використовує slug і SEO metadata як приховані джерела збігу.

Tips мають власний пошуковий контекст: Topic і Tags

Tip відрізняється від Post і News ще одним важливим аспектом.

Для користувача зміст Tip визначає не тільки її власний текст, а й Topic та Tags.

Тому публічний пошук Tips включає:

  • title;
  • short_text;
  • публічні ContentBlocks;
  • topic.name;
  • topic.short_text;
  • tag.name.

Пошук git і #git має давати однаковий результат

Для Tags я додала невелику, але важливу нормалізацію.

Користувач може шукати:

Звичайний запит
git

або:

Запит у вигляді hashtag
#git

Початковий # нормалізується, тому обидва варіанти мають однакову пошукову семантику.

Але це не означає пошук по tag.slug. Search усе одно працює за видимою назвою Tag.

Для мультимовного сайту це одна з найважливіших меж.

На українській сторінці Search шукає тільки українські переклади. На англійській - тільки англійські.

Пошуковий контекст визначає локаль сторінки
/search?q=queue locale = uk-> search only UK content  /en/search?q=queue locale = en-> search only EN content

Я не хочу ситуації, коли український Search повертає Post тільки тому, що слово знайшлося в англійському перекладі.

Такий результат виглядав би дивно: користувач вводить слово, не бачить його в картці, відкриває українську сторінку і все одно не знаходить причини збігу.

Чому cross-locale fallback небезпечний для Search

Інша локаль не повинна створювати прихований збіг
UK search query:pipeline UK title:Автоматизоване розгортання EN title:Automated Deployment Pipeline  Wrong:return UK result because EN matched Correct:UK search evaluates UK content only

Fallback корисний у деяких UI-сценаріях, але public Search має іншу семантику. Він відповідає на питання: "де це слово є в контенті тієї мови, якою я зараз користуюся?".

Як шукати в ContentBlocks без окремого SQL для кожного типу блока

ContentBlocks створили окрему проблему.

Текст може знаходитися не тільки в Paragraph або Rich Text. Він може бути в Heading, FAQ, Cards, Steps, Code, списку, таблиці або новому типі блока, який з’явиться пізніше.

Писати окремий Search mapping для кожного типу було б крихкою архітектурою.

Search не повинен знати структуру кожного блока
Bad direction: Search provider    |    +--> if heading...    +--> if rich_text...    +--> if FAQ...    +--> if cards...    +--> if steps...    +--> if code...    +--> update every time a block type appears

content_blocks.search_text став універсальною текстовою проєкцією

Замість цього ContentBlock має похідне поле search_text.

Його задача - містити тільки текст, який реально бачить користувач.

Одна проєкція для всіх типів ContentBlock
ContentBlock data    |    vContentBlockSearchTextExtractor    |    vvisible text only    |    vsearch_text

HTML перетворюється на звичайний текст. Видимий Code, FAQ, Cards, Steps, Lists та інший контент може бути включений.

Але технічні значення не повинні потрапляти в Search.

  • media ID;
  • URL, який є лише налаштуванням блока;
  • target;
  • variant;
  • alignment;
  • службові enum values;
  • інші параметри відображення.

search_text не визначає public visibility ContentBlock

Це дуже важлива межа.

search_text відповідає тільки на питання: "який видимий текст містить цей block?".

Воно не відповідає на питання: "чи дозволено показувати цей block зараз?".

Текстова проєкція і видимість мають різні обов’язки
search_text:what text is inside the block  public ContentBlock relation:whether this block may participatein public search now

Тому модулі Posts, News і Tips шукають тільки через свої публічні ContentBlocks поточної локалі.

Неактивний block або block іншої локалі може мати заповнений search_text, але це не робить його доступним у public Search.

Чому search_text оновлюється під час кожного Eloquent save

Похідне поле корисне лише тоді, коли воно відповідає актуальному data.

Тому під час нормального збереження ContentBlock через Eloquent search_text автоматично перераховується на основі актуальних даних блока.

Для старих блоків використовується той самий extractor під час окремого backfill.

Одна логіка для нових і старих блоків
new ContentBlock        |        vsaving        |        vextract visible text        |        vsearch_text updated        |        vINSERT / UPDATE  historical ContentBlocks

Search result має пояснювати, чому він був знайдений

Після об’єднання кількох джерел виникла ще одна UX-проблема.

Якщо запит збігся з title, причина очевидна. Але якщо слово знаходиться в середині ContentBlock, Topic або Tag, звичайна картка може взагалі не показувати це слово.

Тому для неочевидного збігу Search повертає короткий контекст.

Контекст пояснює причину результату
Post title:Laravel Observer in Practice  Found in text:"...search_text automatically recalculates..."

Фрагмент береться тільки з публічного ContentBlock поточної локалі, очищується від HTML і обмежується невеликим контекстом навколо збігу.

Не потрібно дублювати snippet, якщо match уже видно в картці

Якщо слово вже видно в title, name, short text або excerpt, додатковий рядок "Знайдено в тексті" тільки перевантажує картку.

Контекст потрібен тоді, коли без нього причина Search result неочевидна.

Джерело збігу

Додатковий контекст

Title / Name

Зазвичай не потрібен

Short text / Excerpt

Не потрібен, якщо fragment уже видно

ContentBlock

Показати "Знайдено у тексті"

Tip Topic

Показати контекст Topic

Tip Tag

Показати Tag або hashtag context

ContentBlock search result має вести до точного місця збігу

Якщо Search знайшов слово в середині великої статті, недостатньо відкрити її на самому початку.

Користувач повинен потрапити до конкретного ContentBlock.

Перехід до конкретного ContentBlock
Search result    |    v/posts/example#content-block-123    |    vscroll to exact block    |    vhighlight matched fragment

Для цього renderer має стабільний технічний target для блока.

Search result додає fragment до звичайного public URL сутності.

Collapsed FAQ або Code потрібно розкрити перед highlight

Ще один edge case - текст знайдено всередині елемента, який на сторінці спочатку закритий.

Наприклад, FAQ answer або collapsible Code.

Користувач має одразу побачити знайдений текст
navigate    |    vfind target block    |    vis matched content hidden?    |    +--> yes -> expand    |    vscroll    |    vhighlight match

Search не повинен приводити до місця, де причина результату залишається прихованою.

Canonical URL залишається відповідальністю самої сутності

Один Search збирає різні типи результатів, але не повинен вигадувати нову URL-схему.

Кожен результат використовує звичайний публічний URL своєї сутності та поточної локалі.

Search використовує звичайні локалізовані URL
UK Post:/posts/example EN Post:/en/posts/example  UK News:/news/example EN News:/en/news/example

Спільний slug і чинний localization layer залишаються джерелом правди. Search тільки отримує правильну адресу результату.

Search cards не повинні ставати другою версією public feed

Posts, News і Tips уже мають публічне представлення у стрічці.

Якби Search отримав окремі майже однакові картки, вони з часом почали б відрізнятися: одна підтримує нове зображення, інша ні; одна показує type badge, інша забула його додати.

Тому Search повторно використовує спільний presentation layer для цих сутностей і додає лише Search-specific context.

Category потребує окремої reusable search card

Category є винятком, бо вона не має такого самого feed-card еквівалента.

Для неї потрібна окрема компактна картка, яка використовує:

  • logo, якщо воно є;
  • optional image;
  • локалізоване name;
  • доречний public short text;
  • безпечний fallback, якщо Media відсутня.

Поведінка пошуку повинна відповідати звичному очікуванню користувача.

Регістр не повинен змінювати результат, а meaningful substring може знаходити довше слово.

Регістр не повинен створювати різні результати
laravelLaravelLARAVEL -> same search semantics  local-> may match localization

Один Search endpoint не означає один SQL query

Це одна з ключових архітектурних думок цієї реалізації.

Мені потрібен один користувацький пошук, але не обов’язково один гігантський SQL-запит, який знає всі доменні правила.

Оркестрація спільна, правила доменів окремі
Public Search    |    +--> Post provider    |      + visibility    |      + current locale    |      + searchable fields    |    +--> Category provider    |      + active state    |      + current locale    |      + searchable fields    |    +--> News provider    |      + visibility    |      + current locale    |      + searchable fields

Це дозволяє додати нову сутність до Search, не переносячи її доменні правила в один центральний монолітний query builder.

Pagination не повинна створювати N+1 у змішаному Search

Після додавання кількох типів контенту легко отримати іншу проблему: Search page працює функціонально, але кожна картка починає окремо завантажувати translation, Media, Topic або Tags.

Тому paginated search result потрібно перевіряти не тільки на правильність даних, а й на контрольовану кількість запитів до бази.

  • локалізовані поля завантажуються передбачувано;
  • Media не створює запит на кожну картку;
  • Tip Topic і Tags не породжують N+1;
  • ContentBlock match context не виконує окремий важкий запит для кожного result;
  • pagination не змінює visibility contract.

Тести для Laravel Search мають перевіряти не тільки успішні matches

Для цього пошуку негативні сценарії не менш важливі за позитивні.

Недостатньо довести, що published Post знаходиться за title. Потрібно довести, що непублічний контент не знаходиться.

Сценарій

Очікування

Published Post

Знаходиться

Draft Post

Не знаходиться

Scheduled Post до published_at

Не знаходиться

Post без Category

Знаходиться, якщо сам публічний

Post у hidden-from-feed Category

Не приховується через Category

Active Category

Знаходиться

Category show_in_public_feed = false

Все одно searchable

Draft News

Не знаходиться

Scheduled News

Не знаходиться раніше часу

Tip tag git

git і #git еквівалентні

Wrong locale translation

Не створює результат

Inactive ContentBlock

Не створює public match

SEO-only match

Не створює результат

Slug-only match

Не створює результат

Особливо важливо тестувати відсутність cross-locale leaks

EN translation не повинна впливати на UK Search
Post has: UK:"Черги в Laravel" EN:"Laravel Queue Workers"  UK search:workers Expected:no match

Цей тест маленький, але дуже добре захищає multilingual contract від випадкового orWhereHas без locale condition.

Що я не роблю в Laravel public search

  • Не шукаю всі сутності за одним однаковим набором умов.
  • Не дублюю вручну visibility logic там, де модуль уже має свій public contract.
  • Не показую Draft або scheduled content лише тому, що translation уже існує.
  • Не використовую іншу локаль як прихований fallback для Search.
  • Не шукаю по slug лише заради більшої кількості results.
  • Не використовую SEO meta fields як невидимі для користувача джерела збігу.
  • Не дозволяю search_text самостійно вирішувати public visibility блока.
  • Не пишу окремий extractor для кожного типу ContentBlock.
  • Не створюю окремий Search-only дизайн для Post, News і Tip.
  • Не веду користувача просто на початок сторінки, якщо match знайдено в конкретному ContentBlock.

Checklist для Laravel Search по кількох типах контенту

  • Для кожної сутності окремо визначити public visibility contract.
  • Для кожної сутності явно визначити searchable fields.
  • Пошук виконувати тільки в поточній локалі.
  • Не використовувати SEO або slug як приховані джерела збігу без окремої причини.
  • Для ContentBlocks мати одну reusable visible-text projection.
  • Не дублювати visibility всередині search_text.
  • Повертати зрозумілий match context для неочевидних результатів.
  • Для ContentBlock match вести до точного блока.
  • Розкривати collapsed content перед highlight.
  • Використовувати canonical public URL відповідної сутності.
  • Повторно використовувати existing public presentation components.
  • Перевіряти query count і N+1.
  • Тестувати Draft, scheduled, wrong locale та inactive blocks як негативні сценарії.

Безпечний public search починається не з SQL LIKE. Він починається з чіткої відповіді на питання, який контент кожна доменна сутність уже має право показати користувачу.

Висновок: один Laravel Search, але не один visibility contract

Об’єднати Posts, Categories, News і Tips в одному Search UI було нескладно. Значно важливішим виявилося не стерти межі між цими сутностями.

Post має власні правила status і published_at. Category достатньо бути активною, і її show_in_public_feed не керує Search. News і Tips мають власний publication contract та локалізований контент. Tips додатково шукаються через Topic і Tags.

Для ContentBlocks я не стала дублювати знання про кожен тип блока всередині Search. Видимий текст один раз проєктується в search_text, а сама публічність і локаль залишаються відповідальністю public ContentBlock relation.

Окремо я зафіксувала жорстке правило локалі: UK Search не шукає EN, а EN Search не шукає UK. Інакше користувач отримує результат, причину якого не може знайти на відкритій сторінці.

Для мене головний висновок простий: один публічний пошук може об’єднати багато доменних сутностей, але він не повинен об’єднувати їхні правила публічності в одну приблизну умову.