Коли в ICanUp з’явилися не тільки Posts, а й Categories, News та Tips, окремий пошук для кожного типу контенту вже не мав сенсу.
Користувач вводить один запит і очікує знайти все релевантне. Але для серверної частини це не означає просто виконати однаковий LIKE по чотирьох таблицях і об’єднати результати.
Кожна сутність має власні правила публічності, локалізації та набору полів, у яких взагалі дозволено шукати.
Найнебезпечніша помилка тут не в тому, що Search пропустить результат. Значно гірше, якщо він покаже Draft, запланований матеріал або текст іншої локалі, який публічна сторінка ще не повинна показувати.

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 написаний окремо й шукає тільки за перекладом:
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 status = true | vmay be searchableshow_in_public_feed тут не є умовою.
Так само Category не зникає з пошуку лише тому, що зараз не має опублікованих Posts.
Laravel Category Search не повинен залежати від feed visibility
Це хороший приклад того, чому не можна механічно повторно використовувати будь-який existing scope лише через слово "public".
Видимість у blog feed і можливість знайти активну Category через Search відповідають на різні питання.
Питання | Поле |
|---|---|
Чи активна Category? |
|
Чи показувати її у blog feed? |
|
Чи може Search знайти активну Category? | Не залежить від feed flag |
Categories шукаються тільки за видимим локалізованим текстом
name;short_text;description.
І знову без slug та SEO-полів.
News: Draft і scheduled content не повинні потрапляти в Search
Для 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або:
#gitПочатковий # нормалізується, тому обидва варіанти мають однакову пошукову семантику.
Але це не означає пошук по tag.slug. Search усе одно працює за видимою назвою Tag.
Multilingual Laravel Search: тільки поточна локаль без прихованого fallback
Для мультимовного сайту це одна з найважливіших меж.
На українській сторінці 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 onlyFallback корисний у деяких UI-сценаріях, але public Search має іншу семантику. Він відповідає на питання: "де це слово є в контенті тієї мови, якою я зараз користуюся?".
Як шукати в ContentBlocks без окремого SQL для кожного типу блока
ContentBlocks створили окрему проблему.
Текст може знаходитися не тільки в Paragraph або Rich Text. Він може бути в Heading, FAQ, Cards, Steps, Code, списку, таблиці або новому типі блока, який з’явиться пізніше.
Писати окремий Search mapping для кожного типу було б крихкою архітектурою.
Bad direction: Search provider | +--> if heading... +--> if rich_text... +--> if FAQ... +--> if cards... +--> if steps... +--> if code... +--> update every time a block type appearscontent_blocks.search_text став універсальною текстовою проєкцією
Замість цього ContentBlock має похідне поле search_text.
Його задача - містити тільки текст, який реально бачить користувач.
ContentBlock data | vContentBlockSearchTextExtractor | vvisible text only | vsearch_textHTML перетворюється на звичайний текст. Видимий 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 ContentBlocksSearch 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.
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 matchSearch не повинен приводити до місця, де причина результату залишається прихованою.
Canonical URL залишається відповідальністю самої сутності
Один Search збирає різні типи результатів, але не повинен вигадувати нову URL-схему.
Кожен результат використовує звичайний публічний 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 відсутня.
Пошук має бути case-insensitive і підтримувати частину слова
Поведінка пошуку повинна відповідати звичному очікуванню користувача.
Регістр не повинен змінювати результат, а 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 |
|
Wrong locale translation | Не створює результат |
Inactive ContentBlock | Не створює public match |
SEO-only match | Не створює результат |
Slug-only match | Не створює результат |
Особливо важливо тестувати відсутність cross-locale leaks
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. Інакше користувач отримує результат, причину якого не може знайти на відкритій сторінці.
Для мене головний висновок простий: один публічний пошук може об’єднати багато доменних сутностей, але він не повинен об’єднувати їхні правила публічності в одну приблизну умову.



