Once ICanUp had not only Posts but also Categories, News, and Tips, separate search experiences for each content type no longer made sense.
A user enters one query and expects to find everything relevant. On the server, however, that does not mean running the same LIKE condition across four tables and merging the results.
Each entity has its own visibility rules, localization rules, and explicitly searchable fields.
The most dangerous failure is not a missing result. It is exposing a Draft, scheduled item, or wrong-locale text that the public application is not supposed to reveal.

Laravel Search Across Multiple Models Does Not Mean One Rule
At the interface level, the architecture looks simple:
Search query | +--> Posts +--> Categories +--> News +--> Tips | vone public result pageThe server-side contract is different for every branch.
Entity | Primary public rule |
|---|---|
Post | Published and published_at has been reached, current locale available |
Category | Category is active, current locale available |
News | Published, published_at reached, current locale available |
Tip | Published, published_at reached, current locale available |
I therefore do not build one universal query that tries to interpret every entity identically.
Search orchestration is shared. Availability rules remain domain-specific.
How Draft Content Can Leak Through Laravel Search
Imagine that a normal Post detail page correctly checks publication status and publication time.
Search is implemented separately and looks only at translations:
Post::query() ->whereHas( 'translations', fn ($query) => $query ->where('locale', $locale) ->where('name', 'title') ->where('value', 'like', '%'.$term.'%') ) ->get();If publication constraints are missing, a Draft can appear in Search while its direct URL correctly returns 404.
To the user, that is a non-public content leak. Architecturally, it means the application has two different answers to the question "is this Post public?".
Search should not invent its own version of entity visibility.
Posts: published_at and status Matter More Than Category
The Post search contract is intentionally independent from Category feed visibility.
A Post can be returned when:
status = published;published_atis not null;published_at <= now().
Category state does not accidentally hide the Post.
A published Post may have no Category, belong to an inactive Category, or belong to a Category with show_in_public_feed = false. If the Post itself is public, those Category properties do not change its Search contract.
Laravel Post Search Uses title, short_text and Public ContentBlocks
For Posts, I search only text with real reader-facing meaning:
- current-locale
title; - current-locale
short_text; - current-locale public ContentBlock text.
I deliberately exclude:
slug;- Category fields;
meta_title;meta_description;meta_keywords;- translations from another locale.
Why Slugs and SEO Meta Fields Should Not Create Search Results
Searching slugs or SEO metadata initially looks like a way to find more content.
It can also produce results whose matching text is completely invisible to the user.
User searches:deployment Visible article text:does not contain deployment Only meta_keywords:deploymentFor internal public search, I prefer another rule: a result should match text the reader can actually see or an explicitly defined meaningful relationship.
Categories Have a Different Public Search Contract
A Category has no published_at and should not inherit Post rules.
For Search, being active is enough:
Category status = true | vmay be searchableshow_in_public_feed is not a Search condition.
A Category also does not disappear from Search merely because it currently has no published Posts.
Laravel Category Search Should Not Depend on Feed Visibility
This is a good example of why an existing scope should not be reused mechanically simply because both features are described as public.
Visibility in the blog feed and discoverability of an active Category through Search answer different questions.
Question | Field or rule |
|---|---|
Is the Category active? |
|
Should it appear in the blog feed? |
|
Can Search find the active Category? | Independent from the feed flag |
Categories Search Only Reader-Visible Localized Text
name;short_text;description.
Again, there is no slug or SEO metadata matching.
News Search Must Exclude Draft and Scheduled Content
News results are limited to content already eligible for public access.
status = published AND published_at IS NOT NULL AND published_at <= now() AND current locale is availableThis protects two separate cases.
A Draft should never appear. Scheduled News should not become searchable before published_at, even when its translation already exists in the database.
News Search Uses title, excerpt and Public ContentBlocks
- current-locale
title; - current-locale
excerpt; - current-locale public ContentBlocks.
Slugs and SEO metadata do not act as hidden matching sources.
Tips Add Their Own Search Context Through Topics and Tags
A Tip differs from Post and News in one more useful way.
Its meaning to the reader can come not only from Tip text but also from its Topic and Tags.
The public Tips search therefore includes:
title;short_text;- public ContentBlocks;
topic.name;topic.short_text;tag.name.
Searching git and #git Should Produce the Same Result
Tags add a small but useful normalization rule.
A user may search for:
gitor:
#gitThe leading # is normalized so both inputs have the same search semantics.
This does not turn into slug matching. Search still targets the visible Tag name.
Multilingual Laravel Search Must Stay in the Current Locale
For a multilingual site, this is one of the most important boundaries.
The Ukrainian Search evaluates Ukrainian translations only. English Search evaluates English translations only.
/search?q=queue locale = uk-> search only UK content /en/search?q=queue locale = en-> search only EN contentI do not want Ukrainian Search to return a Post merely because a word exists in the English translation.
That creates an unexplained result: the user enters a term, cannot see it in the card, opens the Ukrainian page, and still cannot find why the result matched.
Why Cross-Locale Fallback Is Dangerous in 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 behavior can be useful in some interface scenarios, but public Search has different semantics. It answers the question: "where does this term exist in the language I am currently using?".
Searching ContentBlocks Without Per-Type SQL
ContentBlocks introduced a separate problem.
Searchable text can live in Heading, Rich Text, FAQ, Cards, Steps, Code, lists, tables, or a future block type that does not exist yet.
Maintaining separate Search mapping for every block type would be fragile.
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 Is a Universal Text Projection
Instead, each ContentBlock has a derived search_text projection.
Its purpose is to contain only text that a reader can actually see.
ContentBlock data | vContentBlockSearchTextExtractor | vvisible text only | vsearch_textHTML becomes plain text. Visible Code, FAQ, Cards, Steps, lists, and other textual content can participate.
Technical configuration should not.
- media IDs;
- URLs used only as configuration;
target;variant;alignment;- technical enum values;
- other rendering configuration.
search_text Does Not Own ContentBlock Visibility
This is an important architectural boundary.
search_text answers only "which visible text exists inside this block?".
It does not answer "is this block public right now?".
search_text:what text is inside the block public ContentBlock relation:whether this block may participatein public search nowPosts, News, and Tips therefore search only through public ContentBlocks in the current locale.
An inactive or wrong-locale block may have valid search_text but still must not create a public result.
Why search_text Is Rebuilt on Every Normal Eloquent Save
A derived search projection is useful only when it reflects current data.
Whenever a ContentBlock is saved through the normal Eloquent lifecycle, search_text is recalculated from its current data.
Historical blocks use the same extractor through a dedicated backfill operation.
new ContentBlock | vsaving | vextract visible text | vsearch_text updated | vINSERT / UPDATE historical ContentBlocksSearch Results Should Explain Why They Matched
Combining multiple sources creates another UX problem.
If a query matches the title, the reason is obvious. If it matches text deep inside a ContentBlock, Topic, or Tag, the normal card may not display that term at all.
Non-obvious matches therefore need a compact context row.
Post title:Laravel Observer in Practice Found in text:"...search_text automatically recalculates..."The ContentBlock snippet comes only from a public block in the current locale, is converted to plain text, and is limited to a short region around the match.
Do Not Duplicate Match Context When the Match Is Already Visible
If the matching term is already visible in the title, name, short text, or excerpt, an extra "Found in text" line only adds noise.
Context is useful when the reason would otherwise be hidden.
Match source | Extra context |
|---|---|
Title / Name | Usually unnecessary |
Short text / Excerpt | Unnecessary if the fragment is visible |
ContentBlock | Show a compact text context |
Tip Topic | Show Topic context |
Tip Tag | Show Tag or hashtag context |
A ContentBlock Search Result Should Navigate to the Exact Match
If Search finds a term deep inside a long article, opening the top of the page is not enough.
The user should reach the specific ContentBlock.
Search result | v/posts/example#content-block-123 | vscroll to exact block | vhighlight matched fragmentThe public renderer therefore needs a stable technical target for the block.
The Search URL adds a fragment to the normal public entity URL.
Collapsed FAQ or Code Must Be Revealed Before Highlighting
Another edge case appears when the matched text lives inside initially hidden content such as an FAQ answer or collapsible Code block.
navigate | vfind target block | vis matched content hidden? | +--> yes -> expand | vscroll | vhighlight matchSearch should never navigate to a result whose matching text remains hidden and unexplained.
Canonical URLs Remain the Responsibility of Each Entity
One Search aggregates several result types, but it should not invent another URL scheme.
Each result uses its normal public URL in the active locale.
UK Post:/posts/example EN Post:/en/posts/example UK News:/news/example EN News:/en/news/exampleThe shared slug and existing localization layer remain the source of truth. Search only receives the correct destination.
Search Cards Should Not Become a Second Public Feed Design
Posts, News, and Tips already have public feed presentation.
Creating separate nearly identical Search cards would eventually cause drift between the two interfaces.
Search therefore reuses the shared public presentation layer and adds only search-specific context around it.
Category Needs Its Own Reusable Search Component
Category is different because it has no equivalent feed card.
Its compact result component can use:
- logo when available;
- optional image;
- localized
name; - appropriate public short text;
- graceful media fallback.
Search Should Be Case-Insensitive and Support Meaningful Substrings
Search behavior should match normal user expectations.
Letter case should not change the result and meaningful substrings can match longer words.
laravelLaravelLARAVEL -> same search semantics local-> may match localizationOne Search Endpoint Does Not Require One Giant SQL Query
This is one of the central architectural ideas behind the implementation.
I need one user-facing Search experience, but I do not need one giant query builder that understands every domain rule.
Public Search | +--> Post provider | + visibility | + current locale | + searchable fields | +--> Category provider | + active state | + current locale | + searchable fields | +--> News provider | + visibility | + current locale | + searchable fieldsThis makes it possible to add another searchable entity later without moving all of its domain semantics into one central query.
Pagination Must Not Introduce N+1 Queries into Mixed Search Results
Once several content types share one result page, another risk appears: every result card can start loading translations, Media, Topics, or Tags individually.
Paginated Search therefore needs performance verification in addition to functional correctness.
- localized fields are loaded predictably;
- Media does not create one query per card;
- Tip Topic and Tags do not introduce N+1 queries;
- ContentBlock match context does not require one expensive query per result;
- pagination does not change visibility behavior.
Laravel Search Tests Must Cover More Than Successful Matches
Negative scenarios matter as much as positive ones.
It is not enough to prove that a published Post can be found by title. The test suite also needs to prove that non-public content cannot be found.
Scenario | Expected result |
|---|---|
Published Post | Found |
Draft Post | Not found |
Scheduled Post before published_at | Not found |
Post without Category | Found when the Post itself is public |
Post in hidden-from-feed Category | Not hidden by Category state |
Active Category | Found |
Category show_in_public_feed = false | Still searchable |
Draft News | Not found |
Scheduled News | Not found before publication time |
Tip tag git |
|
Wrong-locale translation | Does not create a result |
Inactive ContentBlock | Does not create a public match |
SEO-only match | Does not create a result |
Slug-only match | Does not create a result |
Cross-Locale Leakage Deserves a Dedicated Regression Test
Post has: UK:"Черги в Laravel" EN:"Laravel Queue Workers" UK search:workers Expected:no matchThis small test protects the multilingual contract from an accidental orWhereHas that forgot the locale constraint.
What I Avoid in Laravel Public Search
- Do not search every entity with one identical set of rules.
- Do not duplicate visibility rules when the domain already has a public contract.
- Do not expose Draft or scheduled content because a translation already exists.
- Do not use another locale as a hidden fallback for Search.
- Do not search slugs merely to increase result count.
- Do not use SEO metadata as an invisible match source.
- Do not let search_text determine ContentBlock public visibility.
- Do not implement a separate extractor for every ContentBlock type.
- Do not build separate Search-only presentation for Posts, News, and Tips.
- Do not navigate to the top of a long page when the match belongs to a specific ContentBlock.
Checklist for Laravel Search Across Multiple Content Types
- Define public visibility separately for every searchable entity.
- Define explicit searchable fields for every entity.
- Search current-locale content only.
- Avoid SEO metadata and slugs as hidden match sources without a clear reason.
- Use one reusable visible-text projection for ContentBlocks.
- Keep visibility outside the search_text projection.
- Return understandable context for non-obvious matches.
- Navigate ContentBlock matches to the exact block.
- Reveal collapsed content before highlighting.
- Use the entity canonical public URL.
- Reuse existing public presentation components.
- Verify query count and N+1 behavior.
- Test Draft, scheduled, wrong-locale, and inactive-block scenarios negatively.
Safe public search does not start with SQL LIKE. It starts with a clear answer to which content each domain entity is already allowed to expose.
Conclusion: One Laravel Search Does Not Mean One Visibility Contract
Combining Posts, Categories, News, and Tips into one Search UI was straightforward. The more important part was preserving the boundaries between those entities.
Post has its own status and published_at rules. Category only needs to be active and its show_in_public_feed flag does not control Search. News and Tips have their own publication and localization contracts, while Tips also add Topic and Tag matching.
For ContentBlocks, I avoided teaching Search about every block type. Visible text is projected once into search_text, while visibility and locale remain the responsibility of the public ContentBlock relation.
I also keep locale boundaries strict: Ukrainian Search does not search English content, and English Search does not search Ukrainian content. Otherwise the result may have no visible reason on the page the user opens.
My main conclusion is simple: one public search can combine many domain entities, but it should not combine their publication rules into one approximate condition.



