<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
АРХІТЕКТУРА · MIDDLE ЧАСТО ПИТАЮТЬ

Які патерни проєктування ви реально використовуєте в PHP і які з них перевикористовує фреймворк?

Робочий набір у PHP невеликий: Strategy за інтерфейсом із вибором у фабриці, Decorator для наскрізних дрібниць типу кешу й логів, Observer для реакцій на зміну стану. Фреймворки вже зібрані з них (драйвери кешу і сесій, `#[AsDecorator]`, Eloquent-обсервери), а Singleton у PHP-застосунку реалізує контейнер через `singleton()` чи shared-сервіс, не сам клас.

Назвіть патерн, який ви застосували за останні пів року, і скажіть, що було б, якби ви його не застосували.
Де в Laravel або Symfony ви бачили Strategy, і як фреймворк обирає конкретну реалізацію?
Як зробити singleton у Laravel? (очікують `singleton()` у контейнері, а не `getInstance()` і `private __construct`)
Ми перенесли надсилання листів і списання балів в Eloquent-обсервер, а після нічної команди з масовим `update()` нічого не сталося. Чому?
design patterns Strategy Decorator Observer DI container Laravel Symfony

Практичний набір патернів у PHP-проєкті вміщується в кілька позицій, і майже кожна потрапила в код як відповідь на конкретний біль. Strategy виростає з гілки match, що розповзлася по трьох класах: щойно спосіб розрахунку доставки чи формат експорту треба додати без правки виклику, з'являється інтерфейс і кілька реалізацій. Factory сама по собі майже не потрібна, вона доповнює Strategy: рішення «яку реалізацію взяти» має жити в одному місці й перевірятися одним тестом. Decorator потрібен тоді, коли навколо готового сервісу треба обернути кеш, retry, логування або метрику, а лізти з цим усередину сервісу шкода. Observer тримає модулі на слабкому зв'язку: модуль замовлень нічого не знає про бонуси, він лише повідомляє, що замовлення оплачене. Решта GoF у типовому вебпроєкті або нативна для мови (IteratorAggregate і yield замість Iterator), або трапляється раз на кілька років.

Фреймворки зібрані з цього ж набору наскрізь, тож найшвидше розібратися з патерном можна у вендорському коді. Драйвери кешу, сесій, черг і хешування в Laravel стоять за контрактами Illuminate\Contracts\Cache\Store, SessionHandlerInterface і подібними, тобто це Strategy; CacheManager чи SessionManager працюють до них фабрикою, читають config/cache.php і кешують створений драйвер. Додати власну стратегію дають Cache::extend() і Storage::extend(). У Symfony те саме виглядає явніше: PasswordHasherFactoryInterface обирає хешер під конкретний клас користувача, нормалізатори Serializer вирішують через supportsNormalization(), хто з них обробить об'єкт, транспорти Messenger стоять за TransportInterface, а ServiceLocator із тегованими сервісами замінює магію імен методів. Decorator у Symfony має першокласну підтримку контейнера: #[AsDecorator] з 6.1, decorates у YAML, оригінал під суфіксом .inner, decoration_priority для кількох шарів. Сам фреймворк так і працює: TraceableEventDispatcher обгортає диспетчер у dev, TagAwareAdapter обгортає кеш-адаптер, HttpCache обгортає ядро. У Laravel цю роль виконує $this->app->extend(), а найпомітніший decorator-подібний механізм там - конвеєр middleware поверх Illuminate\Pipeline\Pipeline.

Observer у Laravel має дві форми: модельні обсервери (Order::observe(OrderObserver::class) або атрибут #[ObservedBy] з Laravel 11) з подіями creating, created, updating, updated, deleting, deleted, restored, і звичайні події з слухачами. У Symfony це EventDispatcher із #[AsEventListener], підписники через getSubscribedEvents() і окремо Doctrine-події з #[AsEntityListener]. Різниця між модельним обсервером і доменною подією глибша за синтаксис: перший привʼязаний до життєвого циклу ORM, тому реагує на факт запису рядка, а не на бізнес-факт. Order::query()->where('expires_at', '<', now())->update(['status' => 'expired']) змінить сотні рядків одним SQL-запитом і не кине жодної події, бо моделі не гідрувались. Те саме дають saveQuietly(), updateQuietly() і withoutEvents(), лише вже свідомо. Якщо в обсервері висів обов'язковий крок, він просто не відбувся, і ніхто про це не дізнається до скарги клієнта.

Singleton у цьому списку стоїть окремо: руками його вже не пишуть. private static ?self $instance плюс getInstance() дає три проблеми одразу: залежність стає невидимою в сигнатурах, тест не може підмінити реалізацію, а під Octane, RoadRunner чи FrankenPHP стан переживає запит і протікає до наступного користувача. Контейнер закриває перші дві: $this->app->singleton() у Laravel і shared-сервіси в Symfony (спільні за замовчуванням, shared: false вимикає) керують часом життя зовні, клас лишається звичайним, а в тесті працює $this->instance(...) або static::getContainer()->set(...). Третя проблема лишається і вимагає окремого рішення: усе, що тримає дані конкретного запиту, у Laravel реєструють як scoped(), бо такі binding-и скидаються на кожному запиті воркера. Фасади при цьому не окремий патерн зі своїм станом, а статичний проксі до того ж контейнера, через що Cache::spy() у тесті взагалі можливий.

Межа, за якою патерн починає шкодити, проходить по кількості реалізацій і по видимості потоку керування. Інтерфейс з однією реалізацією і фабрика, яка вміє віддати один клас, не додають гнучкості, зате додають два файли і зайвий стрибок у навігації по коду; поки другої реалізації не видно на горизонті, дешевше залишити конкретний клас і виділити інтерфейс тоді, коли знадобиться. Три декоратори навколо сервісу з одним методом роблять стек-трейс нечитабельним і ховають, який саме шар порахував результат. Обсервер, у якому лежить бізнес-крок, перетворює save() на непередбачувану операцію. Найдорожче обходиться Strategy для того, що змінюється разом: якщо дві «стратегії» завжди правлять в одному комміті, то перед вами один клас із параметром, якому дали два імені. Перед тим як вводити патерн, спробуйте назвати другу реалізацію на імʼя і сказати, коли вона з'явиться. Якщо відповіді немає, з патерном зарано.

// Strategy: правило розрахунку доставки живе за інтерфейсом, а не в гілці if
interface ShippingRate
{
    public function forWeight(int $grams): int; // копійки
}

final class NovaPoshtaRate implements ShippingRate
{
    public function __construct(private NovaPoshtaClient $api) {}

    public function forWeight(int $grams): int
    {
        return $this->api->quote($grams)->minorUnits;
    }
}

// Factory: вибір реалізації винесений з контролера в одне місце
final class ShippingRateFactory
{
    /** @param array<string, class-string<ShippingRate>> $carriers */
    public function __construct(
        private Container $container,
        private array $carriers,
    ) {}

    public function for(string $carrier): ShippingRate
    {
        return $this->container->make(
            $this->carriers[$carrier] ?? throw new UnknownCarrier($carrier),
        );
    }
}

// AppServiceProvider::register()
// Singleton живе в контейнері: сам клас звичайний і підміняється в тестах
$this->app->singleton(ShippingRateFactory::class, fn ($app) => new ShippingRateFactory(
    $app,
    ['nova-poshta' => NovaPoshtaRate::class, 'pickup' => SelfPickupRate::class],
));

// Decorator: кеш додається зовні, реалізації про нього не знають
$this->app->extend(
    NovaPoshtaRate::class,
    fn (ShippingRate $inner, $app) => new CachedRate($inner, $app['cache.store']),
);
Що кандидат називає патерн разом із конкретною проблемою, яку той знімає (гілка `if` по типу оплати, кеш навколо повільного HTTP-клієнта), а не перелічує GoF по пам'яті.
Що він бачить патерни у фреймворку: драйвери кешу, сесій і черг як Strategy з фабрикою-менеджером, `#[AsDecorator]` і `app()->extend()` як Decorator, EventDispatcher і Eloquent-обсервери як Observer.
Що Singleton у PHP-застосунку означає скоуп у контейнері (`singleton`, `scoped`, shared-сервіс Symfony), тому клас лишається звичайним і підмінюється в тестах.
Розуміння ціни Observer: неявний потік керування, залежність від порядку слухачів, пропущені події при масових запитах, робота всередині транзакції.
Що він уміє сказати «тут патерн зайвий»: одна реалізація за інтерфейсом, фабрика на дві назви класу, три шари декораторів навколо тривіального сервісу.
Писати Singleton руками: `private static ?self $instance`, `getInstance()`, приватний конструктор. Такий клас неможливо підмінити в тесті, він тягне стан між запитами під Octane або RoadRunner і перетворює залежність на невидиму.
Плутати Strategy з Factory: інтерфейс і дві реалізації вже є, але вибір лишається в контролері у вигляді `match ($request->method)`, тож додати третій спосіб оплати без правки контролера неможливо.
Розраховувати на Eloquent-обсервер там, де код може піти через `Model::query()->update(...)` чи `->delete()`: query builder не інстанціює модель і модельних подій не кидає, як і `saveQuietly()` або `withoutEvents()`.
Оголошувати Repository, Factory і Strategy на кожну сутність «щоб було розширювано», а потім тримати по одній реалізації кожного інтерфейсу: два файли замість одного і жодної точки розширення, яка б використовувалась.
Ставити декоратор на клас, а не на інтерфейс, і вантажити в нього бізнес-правила: після цього стек-трейс має п'ять кадрів однакових `forWeight()`, і незрозуміло, який шар порахував суму.
ПОРАДА

Відповідайте прикладом зі свого коду і одразу називайте місце у фреймворку, де той самий патерн уже зібраний: «драйвери кешу в Laravel - це Strategy, а `CacheManager` до неї фабрика, яка читає `config/cache.php`». І додайте, що Singleton у вас один на застосунок, і живе він у контейнері.

оновлено 30 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Eloquent кидає `updating`/`updated` у `Model::performUpdate()`, тобто лише коли є завантажений екземпляр. `Order::query()->where(...)->update([...])` виконує один SQL-запит і жодної моделі не гідратує, так само поводиться `->delete()` на білдері. `saveQuietly()` і `withoutEvents()` навпаки, глушать події навмисно. Тому бізнес-крок, обов'язковий для операції, не варто ховати в обсервер: його місце в явному сервісі, який викликають і контролер, і scheduled-команда.