0% прочитано

Strategy Pattern у Laravel Search: різні типи пошуку без switch у сервісі

Публічний пошук у ICanUp працює з Posts, Categories, News і Tips, але кожен тип контенту має власні правила видимості, зв’язки, порядок сортування та поля для пошуку. У статті я показую, як `SearchProviderInterface` і окремі провайдери дозволили оформити ці різні алгоритми як взаємозамінні реалізації Strategy Pattern.

28 вересня 2026 р. 11 хв читанняЛаравель

Коли публічний пошук у ICanUp працював лише з одним типом контенту, окремий архітектурний патерн був би зайвим.

Але згодом система виросла до Posts, Categories, News і Tips. І виявилося, що за словом «пошук» насправді стоять чотири різні алгоритми.

Posts мають категорію, зображення та інформацію про стан читання. Categories мають бути активними й можуть враховувати кількість опублікованих дописів. News використовують власні правила публікації. Tips шукаються не лише за текстом, а й за Topic та Tags.

Спільним залишилося лише те, що потрібно сторінці пошуку: визначити тип контенту, отримати кількість результатів і виконати пошук із пагінацією.

Strategy Pattern тут з’являється не просто через наявність різних моделей. Він потрібен тому, що для однієї операції - публічного пошуку - існує кілька взаємозамінних алгоритмів зі спільним зовнішнім контрактом.

Без Strategy SearchService починав би знати занадто багато

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),

Такий match сам по собі ще не виглядає проблемою. Складність починається всередині кожної його гілки.

Сервіс швидко починає знати, за якими полями перекладів можна шукати, які правила публічної видимості застосовувати, які зв’язки завантажувати наперед, як працювати з Topic і Tags, як сортувати результати та як виконувати пагінацію для кожного типу контенту.

У такій структурі додавання п’ятого типу пошуку означає не лише його реєстрацію, а й чергову зміну центрального класу, який уже містить логіку всіх попередніх типів.

Спільний контракт став межею Strategy

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;}

Цей інтерфейс навмисно залишається невеликим.

type() визначає, який тип пошуку представляє провайдер.

count() повертає кількість результатів для лічильників у вкладках.

search() виконує повний пошук із пагінацією.

У самому контракті немає згадок про Post, News або Tip. Завдяки цьому рівень пошуку може однаково працювати з усіма провайдерами.

SearchService вибирає Strategy, але не виконує її алгоритм

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 знає про набір доступних стратегій, але не знає, як саме виконується пошук для Posts чи Tips.

Вибір потрібної стратегії відбувається через SearchTypeEnum. Після цього код, який викликає пошук, працює з об’єктом через спільний інтерфейс незалежно від його конкретного класу.

Саме тут проходить головна межа Strategy Pattern: контекст обирає потрібну поведінку, але сама реалізація цієї поведінки залишається в окремому об’єкті.

Один інтерфейс, але справді різні алгоритми

Провайдер

За що відповідає стратегія

PostSearchProvider

Шукає публічні Posts, працює з полями, доступними для пошуку, завантажує category, imageMedia та інформацію про стан читання, а також застосовує правила сортування Posts.

CategorySearchProvider

Шукає активні Categories, працює з перекладеними полями категорій і підраховує кількість опублікованих Posts.

NewsSearchProvider

Використовує правила публічної видимості News, локалізований пошук і сортування за датою публікації та id.

TipSearchProvider

Використовує правила видимості Tips, пошук через Topic і Tags, а також власні правила сортування та завантаження зв’язків.

PostSearchProvider може змінюватися незалежно від інших стратегій

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

Стратегія пошуку Posts може окремо змінювати попереднє завантаження зв’язків, стан читання або поля, за якими виконується пошук, не зачіпаючи SearchService.

Це важливо для подальшої підтримки. Якщо згодом Posts потребуватимуть нового зв’язку або іншого порядку ранжування результатів, зміна залишиться локальною для PostSearchProvider.

TipSearchProvider показує, чому одного універсального запиту було б недостатньо

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 мають іншу доменну структуру. Для публічного результату потрібні Topic і Tags, а сам пошук може враховувати додаткові зв’язки.

Якби я намагалася побудувати один універсальний SearchRepository з десятками прапорців на кшталт withTags, withCategory, publishedOnly і searchTopic, я лише перенесла б складність умов в інше місце.

Strategy Pattern дозволяє різним алгоритмам залишатися справді різними.

SearchPage працює зі стратегією пошуку, а не з конкретною моделлю.

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 не перевіряє, чи активний провайдер працює з Post або News. Він просто викликає search() через спільний інтерфейс.

Тут також з’явилася практична оптимізація: результат пошуку для активного провайдера зберігається в межах об’єкта, щоб не виконувати той самий дорогий запит повторно для списку результатів і загальних лічильників.

Для неактивних провайдерів достатньо викликати count(), коли потрібно показати кількість результатів у вкладках.

Laravel tagged services збирають пошукові стратегії

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        ); 

Контейнер Laravel тут відповідає лише за зв’язування залежностей.

Конкретні провайдери реєструються під одним тегом, а SearchService отримує набір доступних стратегій. Завдяки цьому нову реалізацію можна додати без окремого аргументу конструктора для кожного провайдера.

Сам по собі тег контейнера не є Strategy Pattern. Це лише зручний механізм Laravel, який збирає реалізації одного спільного контракту стратегії.

Що потрібно для п’ятого типу пошуку?

  1. Додати нове значення до SearchTypeEnum.

  2. Створити провайдер, який реалізує SearchProviderInterface.

  3. Реалізувати власні count() і search().

  4. Зареєструвати провайдер під спільним тегом Laravel.

  5. Додати Resource і frontend-компонент для відображення результатів нового типу.

При цьому SearchService і SearchPage не повинні отримувати нову гілку з логікою запиту для кожного нового типу.

Чим Strategy відрізняється від Registry з попередньої статті

Registry

Strategy

Головне питання: «Яка реалізація зареєстрована за цим ключем?»

Головне питання: «Який алгоритм потрібно виконати зараз?»

Основний акцент - на пошуку потрібної реалізації та точці розширення.

Основний акцент - на взаємозамінній поведінці.

ContentBlockRegistry повертає опис блока.

Пошуковий провайдер безпосередньо виконує конкретний алгоритм пошуку.

Зареєстрований об’єкт часто описує певну можливість або набір правил.

Об’єкт стратегії безпосередньо виконує потрібну поведінку.

В одному проєкті Registry і Strategy можуть бути дуже близькими за структурою. Різницю між ними краще визначати не за назвою класу, а за відповідальністю: Registry знаходить потрібну реалізацію, а Strategy виконує один із взаємозамінних алгоритмів.

Strategy Pattern спрощує перевірку загальної логіки тестами

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(

Для перевірки загальної логіки не потрібні справжні Eloquent-запити. Достатньо тестових реалізацій того самого інтерфейсу.

Так можна окремо перевірити, що активний провайдер викликається лише один раз, для неактивних провайдерів використовується count(), а порожній пошуковий запит взагалі не запускає жодної стратегії.

Коли Strategy був би зайвим

Якби публічний пошук завжди працював лише з Posts, окремий інтерфейс і набір провайдерів були б зайвим рівнем абстракції.

Strategy Pattern став виправданим тоді, коли з’явилося кілька алгоритмів з однаковою роллю, але справді різною логікою запитів.

Я також не намагалася штучно зробити всі провайдери однаковими всередині. Їхня спільність закінчується на спільному контракті. Завдяки цьому кожен тип пошуку може зберігати власні доменні правила.

Хороша абстракція Strategy не приховує відмінності між алгоритмами. Вона лише звільняє код, який їх викликає, від необхідності знати про ці відмінності.

Висновок

У ICanUp Strategy Pattern у пошуку з’явився через просту потребу: Posts, Categories, News і Tips мали працювати на одній сторінці пошуку, але не могли використовувати один універсальний запит.

SearchProviderInterface залишив спільним лише те, що справді є спільним: тип пошуку, підрахунок результатів і пошук із пагінацією. Уся логіка, специфічна для конкретного типу контенту, залишилася в окремих провайдерах.

SearchService вибирає потрібну стратегію. SearchPage працює з нею через спільний контракт. Контейнер Laravel збирає доступні реалізації.

Завдяки цьому новий тип пошуку розширює систему новою поведінкою, а не додає ще одну гілку умов до центрального сервісу.