Практичний набір патернів у 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']),
);
Відповідайте прикладом зі свого коду і одразу називайте місце у фреймворку, де той самий патерн уже зібраний: «драйвери кешу в Laravel - це Strategy, а `CacheManager` до неї фабрика, яка читає `config/cache.php`». І додайте, що Singleton у вас один на застосунок, і живе він у контейнері.