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.
Without Strategy SearchService Would Know Too Much
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
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
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 $providerSearchService 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
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 $thisThe 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
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 $thisTips 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
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
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
- Add a new value to
SearchTypeEnum. - Create a provider implementing
SearchProviderInterface. - Implement its own
count()andsearch(). - Register the provider under the shared Laravel tag.
- 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. |
Strategy Makes Orchestration Easier to Test
$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.



