0% прочитано

Strategy Pattern in Laravel Search: Multiple Search Types Without a Switch in the Service

Public Search in ICanUp searches Posts, Categories, News, and Tips, but every type has its own visibility rules, relations, ordering, and searchable fields. This article shows how SearchProviderInterface and dedicated providers turned those algorithms into interchangeable Strategy implementations.

28 вересня 2026 р. 11 хв читанняLaravel

When Public Search in ICanUp supported only one content type, introducing a separate architecture pattern would have been unnecessary.

The system later grew to Posts, Categories, News, and Tips. At that point the word "search" was hiding four different algorithms.

Posts need category, image, and reading state. Categories need their own visibility and published-post counts. News has its own publication contract. Tips can match not only text but Topic and Tags.

The only stable part was what the Search page needed: identify the type, count results, and run paginated search.

The Strategy Pattern is not here merely because the models are different. It appears because one operation - public search - has interchangeable algorithms behind the same external contract.

Without Strategy SearchService Would Know Too Much

PHP - The central service owns every search algorithm
public function search(    SearchTypeEnum $type,    string $query,): LengthAwarePaginator {    return match ($type) {        SearchTypeEnum::POSTS =>            $this->searchPosts($query),         SearchTypeEnum::CATEGORIES =>            $this->searchCategories($query),         SearchTypeEnum::NEWS =>            $this->searchNews($query),         SearchTypeEnum::TIPS =>            $this->searchTips($query),

This match does not look dangerous yet. The real problem begins inside each branch.

The service quickly starts knowing searchable translation fields, publication scopes, eager loading, Topic and Tag relations, ordering, and pagination rules for every content type.

Adding a fifth type then changes not only registration but also the central class that already contains the logic of every previous type.

The Shared Contract Became the Strategy Boundary

PHP - Search strategy contract
interface SearchProviderInterface{    public function type(): SearchTypeEnum;     public function count(        string $query,    ): int;     public function search(        string $query,        int $perPage = 12,    ): LengthAwarePaginator;}

The interface is intentionally small.

type() identifies the strategy.

count() supports result totals in tabs.

search() executes the full paginated query.

The contract says nothing about Post, News, or Tip. That lets the Search layer work with every provider in the same way.

SearchService Selects the Strategy Without Owning Its Algorithm

PHP - Select the active search strategy
final readonly class SearchService{    /**     * @param iterable<SearchProviderInterface> $providers     */    public function __construct(        private iterable $providers,    ) {}     public function provider(        SearchTypeEnum $type,    ): SearchProviderInterface {        return collect($this->providers)            ->firstOrFail(                fn (                    SearchProviderInterface $provider

SearchService knows about a collection of strategies, but it does not know how Posts or Tips are actually searched.

Selection happens through SearchTypeEnum. The caller then receives an object with the same interface regardless of its concrete class.

This is the important Strategy boundary: the context selects behavior while the implementation of that behavior lives in a separate object.

One Interface, but Genuinely Different Algorithms

Provider

What the Strategy Does

PostSearchProvider

Searches public Posts, handles searchable text, loads category, imageMedia, and reading state, and uses Post ordering.

CategorySearchProvider

Searches active Categories, matches category translations, and counts published posts.

NewsSearchProvider

Uses the News visibility contract, localized search, and publication-date plus id ordering.

TipSearchProvider

Uses Tip visibility, Topic and Tags, plus its own ordering and relations.

PostSearchProvider Can Evolve Independently

PHP - Post search strategy
final readonly class PostSearchProvider    implements SearchProviderInterface{    public function __construct(        private Post $posts,    ) {}     public function type(): SearchTypeEnum    {        return SearchTypeEnum::POSTS;    }     public function count(        string $query,    ): int {        return $this

The Post strategy can change eager loading, reader state, or searchable fields without changing SearchService.

This matters for maintenance. If Posts later need another relation or a different ranking rule, that change remains local to the Post strategy.

TipSearchProvider Shows Why One Generic Query Would Be Too Weak

PHP - Tip search strategy
final readonly class TipSearchProvider    implements SearchProviderInterface{    public function __construct(        private Tip $tips,    ) {}     public function type(): SearchTypeEnum    {        return SearchTypeEnum::TIPS;    }     public function count(        string $query,    ): int {        return $this

Tips have a different domain shape. Public results need Topic and Tags, and matching can include additional relations.

If I tried to build one universal SearchRepository with flags such as withTags, withCategory, publishedOnly, and searchTopic, I would only move conditional complexity somewhere else.

Strategy allows different algorithms to remain different.

SearchPage Works With a Strategy, Not a Concrete Model

PHP - The page delegates search to the active strategy
final class SearchPage{    private bool $resultsResolved =        false;     private ?LengthAwarePaginator $results =        null;     public function __construct(        private readonly string $query,        private readonly SearchProviderInterface $activeProvider,        private readonly array $providers,    ) {}     public function results(): ?LengthAwarePaginator    {

SearchPage does not check whether the active provider is Post or News. It simply calls search().

A practical optimization also fits naturally here: the active search result is cached inside the object so the same expensive query is not executed again for both the result list and totals.

Inactive providers only need count() when tab totals are required.

Laravel Tagged Services Assemble the Strategies

PHP - Collect strategies with Laravel container tags
final class SearchServiceProvider    extends ServiceProvider{    private const string PUBLIC_SEARCH_PROVIDERS_TAG =        'public-search.providers';     public function register(): void    {        $this->app->bind(            PostSearchProvider::class        );         $this->app->bind(            CategorySearchProvider::class        ); 

The Laravel container is responsible only for wiring here.

Concrete providers are registered under one tag, and SearchService receives an iterable of strategies. A new implementation does not require another dedicated constructor argument.

The container tag is not the Strategy Pattern itself. It is simply a convenient Laravel mechanism for collecting implementations of the same strategy contract.

What a Fifth Search Type Would Require

  1. Add a new value to SearchTypeEnum.
  2. Create a provider implementing SearchProviderInterface.
  3. Implement its own count() and search().
  4. Register the provider under the shared Laravel tag.
  5. Add the Resource and frontend result component for the new type.

SearchService and SearchPage should not need another branch containing query logic.

How Strategy Differs From the Registry in the Previous Article

Registry

Strategy

Main question: "which implementation is registered under this key?"

Main question: "which algorithm should run now?"

Focuses on lookup and extension points.

Focuses on interchangeable behavior.

ContentBlockRegistry returns a definition.

A Search provider executes a concrete search algorithm.

The registered object often describes a capability.

The Strategy object directly performs behavior.

Registry and Strategy can look very similar in the same codebase. The useful distinction is responsibility: resolving an implementation or executing interchangeable behavior.

Strategy Makes Orchestration Easier to Test

PHP - Test orchestration with interchangeable strategies
$posts = new SearchProviderFake(    SearchTypeEnum::POSTS,    4,); $categories = new SearchProviderFake(    SearchTypeEnum::CATEGORIES,    3,); $news = new SearchProviderFake(    SearchTypeEnum::NEWS,    2,); $tips = new SearchProviderFake(

Testing orchestration does not require real Eloquent queries. Fake implementations of the same interface are enough.

This makes it possible to verify separately that the active provider runs once, inactive providers use count(), and an empty query executes no strategy at all.

When Strategy Would Be Overkill

If Public Search always handled only Posts, an interface plus a provider collection would be unnecessary abstraction.

Strategy became justified when multiple algorithms appeared with the same role but genuinely different query logic.

I also did not try to make the providers artificially identical internally. Their commonality ends at the contract, which lets every search type preserve its own domain rules.

A good Strategy abstraction does not erase differences between algorithms. It prevents the caller from needing to know those differences.

Conclusion

In ICanUp, the Strategy Pattern in Search started with a simple need: Posts, Categories, News, and Tips had to live on one Search page without pretending they could share one universal query.

SearchProviderInterface keeps only what is truly common: type, count, and paginated search. Domain-specific logic remains inside concrete providers.

SearchService selects the strategy. SearchPage executes it. The Laravel container assembles the available implementations.

As a result, a new search type extends the system with new behavior instead of adding another branch to a central service.