php
artykuł··zaktualizowano ·8 min czytania

Wzorce projektowe w produkcyjnym PHP: co dają, ile kosztują, kiedy ich nie używać

#design-patterns #architecture #php #refactoring #code-review

Wzorzec projektowy to nazwa dla powtarzalnego kształtu kodu. Przede wszystkim ułatwia rozmowę. Recenzent widzi CachingUserRepository implements UserRepositoryInterface, rozpoznaje dekorator i wie, jak klasa jest podpięta, co potrafi i jak ją testować, zanim zajrzy do metody.

Płaci się za to warstwą pośrednią. Prawie każdy wzorzec wstawia co najmniej jeden interfejs albo jedną klasę między wywołującego a kod, który robi robotę. Ten koszt ponosisz od razu, przy każdym czytaniu. Zysk przychodzi dopiero wtedy, gdy zmiana, pod którą wzorzec powstał, naprawdę się wydarzy.

Poniżej przechodzę przez wzorce GoF z perspektywy aplikacyjnego PHP: Laravel albo Symfony, stan żyjący przez jedno żądanie, kontener DI pod ręką. Przy każdym patrzę, jaki problem rozwiązuje, jak się go zwykle nadużywa i czy kontener albo sam język nie robi już tego za niego.

Test przed użyciem wzorca

Odpowiedz na jedno pytanie: jaką przyszłą zmianę ten wzorzec robi tańszą?

WzorzecZmiana, którą ułatwia
AdapterWymiana albo aktualizacja zewnętrznej zależności
DecoratorDodanie lub zdjęcie zachowania przekrojowego (cache, logowanie, rate limiting)
StrategyNowy wariant algorytmu bez ruszania wywołującego
Observer / EventNowa reakcja na zmianę stanu bez ruszania jej źródła
CommandPrzeniesienie pracy na kolejkę, ponawianie, audyt
Factory MethodNowa implementacja wybierana w czasie wykonania

Jeśli tej zmiany nie da się nazwać albo jest hipotetyczna („może kiedyś zmienimy bazę”), wzorzec powstaje na zapas. Napisz wersję bezpośrednią, a wzorzec wprowadź w commicie, który go potrzebuje. Wzorzec łatwo dołożyć, kiedy pojawi się drugi wariant, a do tego czasu nie trzeba utrzymywać nieużywanej abstrakcji.

Wzorce kreacyjne

Singleton. Ma sens dla niezmiennego stanu, który drogo się buduje i który współdzieli jeden proces. We frameworku z kontenerem zarejestruj klasę jako współdzielone wiązanie ($this->app->singleton(...) w Laravelu), zamiast pisać statyczne getInstance(). Wersję z kontenera da się wstrzyknąć i podmienić w testach, statycznego getInstance() już nie. Osobny artykuł.

Factory Method. Przydaje się, gdy konkretny typ wybierasz dopiero w czasie wykonania: bramkę płatności według kraju, parser według sygnatury pliku, kanał powiadomień według ustawień użytkownika. Dobra fabryka wybiera typ, budowę obiektu zostawia kontenerowi i zwraca interfejs. Osobny artykuł.

Builder pasuje do obiektów składanych krokami i na końcu zamrażanych. Query builder zbiera warunki i oddaje jedno zapytanie. W testach buildery do fixture’ów są w porządku, testy łatwiej się wtedy czyta. Inaczej jest, gdy kod produkcyjny potrzebuje buildera tylko po to, żeby złożyć encję. Zwykle znaczy to, że konstruktor encji przyjmuje za dużo argumentów, i lepiej podzielić encję albo część argumentów zamknąć w value objectach.

Abstract Factory tworzy rodziny powiązanych obiektów, które muszą do siebie pasować, na przykład zestaw kontrolek UI dla jednego motywu. W backendzie większość takich przypadków załatwia kontekstowe wiązanie w kontenerze, więc ręcznie pisana fabryka abstrakcyjna zdarza się rzadko.

Prototype PHP ma wbudowany jako clone i __clone(). Interfejs Prototype z metodą copy(), która woła clone $this, dokłada warstwę i nic poza nią. Głęboką kopię zagnieżdżonych obiektów robi się w __clone().

Wzorce strukturalne

Adapter

Każda integracja z zewnętrznym API jest adapterem: tłumaczy typy i błędy dostawcy na typy i błędy twojej domeny. Adapter powinien tylko tłumaczyć. Polityka ponowień, liczenie prowizji i decyzje biznesowe trafiają do serwisu, który zależy od interfejsu adaptera.

final class StripePaymentGateway implements PaymentGatewayInterface
{
    public function __construct(private readonly \Stripe\StripeClient $stripe) {}

    public function charge(Money $amount, string $paymentMethodId): ChargeResult
    {
        try {
            $intent = $this->stripe->paymentIntents->create([
                'amount'                       => $amount->minorUnits,  // Stripe oczekuje kwoty w najmniejszej jednostce waluty
                'currency'                     => strtolower($amount->currency),
                'payment_method'               => $paymentMethodId,
                'allowed_payment_method_types' => ['card'],
                'confirm'                      => true,                 // bez tego nic nie zostaje pobrane i nie pojawia się błąd karty
            ]);

            return $intent->status === \Stripe\PaymentIntent::STATUS_SUCCEEDED
                ? ChargeResult::succeeded($intent->id)
                : ChargeResult::pending($intent->id);
        } catch (\Stripe\Exception\CardException $e) {
            return ChargeResult::declined($e->getMessage());
        }
    }
}

Ten adapter mapuje Money na tablicę żądania, odpowiedź na ChargeResult, a odrzucenie karty zamienia na wynik domenowy. To szkic pod wersję API Stripe 2026-09-30.endive (przypiętą w stripe-php 22), która odrzuca payment_method_types, więc listę metod zawęża allowed_payment_method_types. Prawdziwy adapter odróżniałby jeszcze requires_action (3DS) od processing i mapował pozostałe wyjątki Stripe, na przykład błąd połączenia. Kiedy taka klasa zaczyna o czymś decydować, na przykład czy ponowić odrzuconą kartę, urosła do serwisu i trzeba ją rozdzielić.

Decorator

Dekorator opakowuje obiekt implementujący ten sam interfejs i dokłada zachowanie przed delegacją albo po niej. Zwykle chodzi o cache, logowanie, metryki i ograniczanie ruchu.

use Illuminate\Contracts\Cache\Repository as Cache;

final class CachingUserRepository implements UserRepositoryInterface
{
    public function __construct(
        private readonly UserRepositoryInterface $inner,
        private readonly Cache $cache,
        private readonly int $ttlSeconds = 300,
    ) {}

    public function findById(int $id): ?User
    {
        return $this->cache->remember(
            "user.{$id}",
            $this->ttlSeconds,
            fn () => $this->inner->findById($id),
        );
    }
}

Podpięcie w service providerze:

use Illuminate\Contracts\Cache\Repository as Cache;

$this->app->bind(UserRepositoryInterface::class, fn ($app) => new CachingUserRepository(
    $app->make(EloquentUserRepository::class),
    $app->make(Cache::class),
));

Każda warstwa ma osobny test. Test dekoratora sprawdza tylko pudło i trafienie, dostęp do danych zostaje w teście repozytorium.

Kłopoty zaczynają się przy długich łańcuchach dekoratorów, składanych w różnej kolejności w różnych miejscach. Jeśli żeby ustalić, czy wywołanie jest cache’owane, logowane i ponawiane, trzeba prześledzić konfigurację kontenera, stos jest za głęboki. Dwie, trzy warstwy podpięte w jednym miejscu to rozsądna granica. I szczegół tego konkretnego dekoratora: remember() w Laravelu traktuje zapisany null jak brak wpisu, więc zapytania o nieistniejące ID zawsze trafiają do bazy. Jeśli ten ruch ma znaczenie, zapisuj w cache’u jawny znacznik „nie znaleziono”.

Facade i Proxy

Fasada daje jeden punkt wejścia do podsystemu złożonego z wielu klas, gdy wywołującym wystarcza kilka operacji. Statyczne fasady Laravela to inny mechanizm pod tą samą nazwą, statyczne proxy do wiązania w kontenerze. W kodzie aplikacji są wygodne i testuje się je przez ::fake() albo ::shouldReceive(). Chowają za to zależności, bo w sygnaturze konstruktora nic się nie pojawia. W serwisach domenowych wolę wstrzykiwanie przez konstruktor, wtedy lista zależności jest na wierzchu.

Proxy w PHP spotyka się głównie jako klasy generowane do leniwego ładowania przez ORM-y i kontenery, najczęściej encje Doctrine i lazy services w Symfony. Od PHP 8.4 oba mogą zamiast generowanych klas korzystać z natywnych lazy objects. Własne rzadko ma uzasadnienie. Do przechwytywania wywołań wystarczy dekorator, robi to samo jawnie i łatwiej go przetestować.

Wzorce behawioralne

Observer i zdarzenia

Kiedy Order zostaje opłacone, kod oznaczający płatność wysyła zdarzenie OrderPaid. Listenery od maili, stanów magazynowych i analityki subskrybują je niezależnie, a dołożenie czwartego nie zmienia kodu zamówienia.

use Illuminate\Contracts\Queue\ShouldQueue;

final class OrderPaid
{
    public function __construct(public readonly int $orderId) {}
}

final class ReserveInventory implements ShouldQueue
{
    public function handle(OrderPaid $event): void
    {
        // wczytaj zamówienie po id i zarezerwuj towar
    }
}

Ryzykiem są kaskady zdarzeń. Listener wysyła kolejne zdarzenie, jego listener następne i powstaje graf wywołań, którego nikt nigdy nie rozrysował. Synchroniczne listenery w takim łańcuchu wykonują się w ramach pierwotnego żądania, więc czas odpowiedzi i liczba zapytań prostego „oznacz jako opłacone” rosną z każdym subskrybentem. W ryzach trzyma to kilka zasad:

  • Listenery z efektami ubocznymi poza bazą (mail, wywołania HTTP) implementują ShouldQueue.
  • Zdarzenia niosą identyfikatory, nie modele. Listener sam ładuje aktualny stan.
  • Listenery, które nie mogą ruszyć przed commitem transakcji, są tak oznaczone: ShouldDispatchAfterCommit na zdarzeniu albo $afterCommit = true na listenerze kolejkowym.

Strategy

Strategy oddziela wybór algorytmu od jego użycia. Podręcznikowy przypadek to kalkulator wysyłki ze stawką ryczałtową, wagową i strefową.

interface ShippingPricing
{
    public function price(Parcel $parcel): Money;
}

final class ShippingQuote
{
    /** @param array<string, ShippingPricing> $pricings */
    public function __construct(private readonly array $pricings) {}

    public function for(Carrier $carrier, Parcel $parcel): Money
    {
        return ($this->pricings[$carrier->value] ?? throw new UnsupportedCarrier($carrier))
            ->price($parcel);
    }
}

Strategię wybieraj raz, na brzegu operacji. Gdy wybór siedzi w pętli po pozycjach, trudniej go śledzić, bo to samo pytanie pada przy każdej pozycji. Przy dwóch wariantach, kiedy trzeciego nie widać, wyrażenie match jest krótsze i równie czytelne.

Command i Template Method

Komenda to serializowalny opis intencji, ChargeCustomer(customerId: 42, amountMinor: 1999). Że jest danymi, można ją wrzucić na kolejkę, opóźnić, ponowić, zalogować i odtworzyć. Kolejkowany job w Laravelu to komenda, która zwykle niesie własną metodę handle(), a osobny handler można podpiąć przez Bus::map. Najwięcej myślenia wymaga payload. Trzymaj w nim identyfikatory i skalary, a handler zrób idempotentny, bo kolejka z ponowieniami może dostarczyć tę samą komendę więcej niż raz.

Template Method przydaje się, gdy dwie klasy mają wspólną, stałą sekwencję kroków i różnią się jednym, na przykład raport budowany tak samo i eksportowany do CSV albo do PDF. Klasa bazowa definiuje sekwencję, a zmienny krok deklaruje jako abstrakcyjny. Takie dziedziczenie wystarcza, dopóki różni się jeden krok. Gdy różnych kroków przybywa albo warianty trzeba łączyć, przejdź na kompozycję i wstrzyknij eksporter jako strategię.

Wzorce, które rzadko pasują do kodu aplikacji

Interpreter to reprezentacja gramatyki własnego języka i ewaluacja jej drzewa, a w praktyce dochodzi do tego jeszcze parser. Zanim go napiszesz, sprawdź gotowy silnik wyrażeń. Symfony ExpressionLanguage obsłuży większość reguł biznesowych, warunki cenowe czy predykaty feature flag, przy dużo mniejszej ilości kodu do utrzymania.

Mediator to centralny obiekt koordynujący komponenty, które inaczej odwoływałyby się do siebie nawzajem. Jeśli framework ma dispatcher zdarzeń i kontener, dispatcher zwykle już pełni tę rolę. Własny mediator ma sens, gdy sama koordynacja jest złożona i ma stan, jak w formularzu, w którym pola wpływają na siebie.

Flyweight współdzieli niezmienny stan między wieloma drobnymi obiektami, żeby oszczędzić pamięć. W PHP obiekty utworzone dla żądania znikają po jego zakończeniu, a jedno żądanie rzadko tworzy ich tyle, żeby to miało znaczenie. Wyjątkiem są długo działające zadania CLI, które tworzą ogromne ilości identycznych value objectów, na przykład tokenów w parserze.

Zanim zatwierdzisz PR

  • Czy autor umie nazwać zmianę, którą wzorzec ułatwia, i czy ta zmiana jest naprawdę zaplanowana?
  • Czy nowy interfejs ma drugą implementację albo przynajmniej dublera w testach, który go potrzebuje?
  • Czy adapter tylko tłumaczy?
  • Czy match albo zwykła funkcja nie załatwiłyby tego samego krótszym kodem?
koniec artykułu