0% прочитано

Laravel Search Across Multiple Content Types Without Leaking Draft Content

ICanUp has one public search across Posts, Categories, News, and Tips, but those entities have different visibility rules and different searchable fields. This article explains why search should not duplicate Draft, scheduling, locale, and ContentBlock visibility rules, how non-public content stays out of results, and why UK and EN search remain strictly separated.

21 вересня 2026 р. 13 хв читанняLaravel

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.

Public search is another public entry point into content. It must respect the same visibility boundaries as detail pages, feeds, category pages, and other public collections.
Laravel public search architecture with Posts Categories News and Tips passing through separate visibility and locale rules before joining one search result stream
One Search UI can display several entity types, but each entity first passes through its own public visibility and locale contract.

Laravel Search Across Multiple Models Does Not Mean One Rule

At the interface level, the architecture looks simple:

One query across several content types
Search query    |    +--> Posts    +--> Categories    +--> News    +--> Tips    |    vone public result page

The 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:

PHP - Search without a public visibility contract
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_at is 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.

An invisible field creates an unexplained result
User searches:deployment Visible article text:does not contain deployment Only meta_keywords:deployment

For 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 visibility follows its own state
Category status = true        |        vmay be searchable

show_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?

status

Should it appear in the blog feed?

show_in_public_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.

News search visibility contract
status = published AND published_at IS NOT NULL AND published_at <= now() AND current locale is available

This 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:

Plain query
git

or:

Hashtag-shaped query
#git

The 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.

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.

The page locale defines the search context
/search?q=queue locale = uk-> search only UK content  /en/search?q=queue locale = en-> search only EN content

I 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

Another locale must not create a hidden match
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 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.

Search should not know every block structure
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 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.

One text projection for every ContentBlock type
ContentBlock data    |    vContentBlockSearchTextExtractor    |    vvisible text only    |    vsearch_text

HTML 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?".

Text projection and visibility are separate responsibilities
search_text:what text is inside the block  public ContentBlock relation:whether this block may participatein public search now

Posts, 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.

One extraction rule for runtime and historical data
new ContentBlock        |        vsaving        |        vextract visible text        |        vsearch_text updated        |        vINSERT / UPDATE  historical ContentBlocks

Search 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.

Context explains the match
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.

Navigate directly to the matching block
Search result    |    v/posts/example#content-block-123    |    vscroll to exact block    |    vhighlight matched fragment

The 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.

The user should immediately see the matching text
navigate    |    vfind target block    |    vis matched content hidden?    |    +--> yes -> expand    |    vscroll    |    vhighlight match

Search 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.

Search reuses normal localized URLs
UK Post:/posts/example EN Post:/en/posts/example  UK News:/news/example EN News:/en/news/example

The 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 behavior should match normal user expectations.

Letter case should not change the result and meaningful substrings can match longer words.

Letter case should not change matching semantics
laravelLaravelLARAVEL -> same search semantics  local-> may match localization

One 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.

Shared orchestration with domain-specific rules
Public Search    |    +--> Post provider    |      + visibility    |      + current locale    |      + searchable fields    |    +--> Category provider    |      + active state    |      + current locale    |      + searchable fields    |    +--> News provider    |      + visibility    |      + current locale    |      + searchable fields

This 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

git and #git behave equivalently

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

English translation must not affect Ukrainian Search
Post has: UK:"Черги в Laravel" EN:"Laravel Queue Workers"  UK search:workers Expected:no match

This 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.