php
article··updated ·9 min read

Design patterns in production PHP: what each one buys, what it costs, when to skip it

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

A design pattern is a name for a recurring shape of code. Its main value is communication. When a reviewer reads CachingUserRepository implements UserRepositoryInterface, they recognize a decorator and know how the class is wired, what it can do and how to test it, before they read a line of the body.

The cost is indirection: almost every pattern puts at least one interface or one class between the caller and the code that does the work. It only pays off if the change the pattern was built for actually happens.

This note goes through the GoF patterns from the perspective of application-layer PHP: Laravel or Symfony, request-scoped state, a DI container available. For each one I look at the problem it addresses, the typical misuse, and whether the container or the language already does the job.

The test before applying any pattern

Answer one question: which future change does this make cheaper?

PatternChange it makes cheaper
AdapterReplacing or upgrading an external dependency
DecoratorAdding or removing a cross-cutting concern (cache, logging, rate limiting)
StrategyAdding a new algorithm variant without touching the caller
Observer / EventAdding a new reaction to a state change without touching its source
CommandMoving work to a queue, retrying it, auditing it
Factory MethodAdding a new implementation selected at runtime

If you can't name the change, or it's hypothetical ("we might switch databases"), the pattern is speculative. Write the direct version and introduce the pattern in the commit that needs it.

Creational patterns

Singleton. Legitimate for immutable, expensive-to-build state shared within one process. In a framework with a container, register the class as a shared binding ($this->app->singleton(...) in Laravel) instead of writing a static getInstance(). You can inject the container version and swap it in tests. A static getInstance() is hard-wired into every caller. Separate article.

Factory Method. Needed when the concrete type is a runtime decision: payment gateway by country, parser by file signature, notification channel by user preference. A good factory selects the type, lets the container do the construction and returns an interface. Separate article.

Builders fit objects that are assembled in steps and then frozen. A query builder collects conditions and produces one query. Builders for test fixtures are fine too, because they make tests easier to read. It's different when production code needs a builder just to construct an entity. That usually means the entity constructor takes too many arguments, and the better fix is to split the entity or pull some of those arguments into value objects.

Abstract Factory produces families of related objects that have to match each other, like a set of UI widgets for one theme. In backend code the container's contextual binding covers most of these cases, so a hand-written one is rare.

Prototype is built into PHP as clone and __clone(). A Prototype interface with a copy() method that calls clone $this adds a layer and no behavior. If nested objects need deep copies, implement __clone().

Structural patterns

Adapter

Every integration with an external API is an adapter: it translates the vendor's types and errors into your domain's types and errors. An adapter should only translate. Retry policy, fee calculation and business decisions go into a service that depends on the adapter's interface.

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 expects the smallest currency unit
                'currency'                     => strtolower($amount->currency),
                'payment_method'               => $paymentMethodId,
                'allowed_payment_method_types' => ['card'],
                'confirm'                      => true,                 // without it nothing is charged and no card error is raised
            ]);

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

This one maps Money to the request array, maps the response to ChargeResult and turns a card decline into a domain outcome. It's a sketch against Stripe API version 2026-09-30.endive (pinned by stripe-php 22), which rejects payment_method_types, so the accepted methods are narrowed with allowed_payment_method_types. A real adapter would also handle requires_action (3DS) separately from processing and map the other Stripe exceptions, a connection error for example. When a class like this starts deciding things, for example whether to retry a declined card, it has grown into a service and should be split.

Decorator

A decorator wraps an object that implements the same interface and adds behavior before or after delegating. Caching, logging, metrics and rate limiting are the standard uses.

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

Wiring in a service provider:

use Illuminate\Contracts\Cache\Repository as Cache;

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

Each layer gets its own test. The decorator test covers only the miss and the hit, and data access stays in the repository test.

Where it goes wrong is long chains, assembled in a different order in different contexts. If a reader has to trace the container configuration to find out whether a call is cached, logged and retried, the stack is too deep. Two or three layers wired in one place is a reasonable limit. One detail specific to this decorator: Laravel's remember() treats a stored null as a miss, so lookups for IDs that don't exist always reach the database. If that traffic matters, cache an explicit "not found" marker.

Facade and Proxy

A facade gives one entry point to a subsystem of many classes when callers need only a few operations from it. Laravel's static facades are a different mechanism under the same name, a static proxy to a container binding. They're convenient in application code and testable through ::fake() or ::shouldReceive(). What they hide is the dependency list, because nothing shows up in the constructor signature. In domain services I'd use constructor injection so that list stays visible.

You mostly meet proxies as classes that ORMs and containers generate for lazy loading, Doctrine entities and Symfony lazy services being the usual ones. Since PHP 8.4 both can use native lazy objects instead of generated classes. Writing your own is rarely justified. To intercept calls, a decorator does the same job explicitly and is easier to test.

Behavioral patterns

Observer / events

When Order becomes paid, the code that marks it paid dispatches OrderPaid. Email, inventory and analytics listeners subscribe independently, and adding a fourth listener doesn't change the order code.

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
    {
        // load the order by id and reserve stock
    }
}

Cascading events are the risk. A listener dispatches another event, that event's listener dispatches a third one, and you get a call graph nobody ever wrote down. Synchronous listeners in such a chain all run inside the original request, so latency and query count of a simple "mark as paid" grow with every subscriber. What keeps it under control:

  • Listeners with side effects outside the database (email, HTTP calls) implement ShouldQueue.
  • Events carry identifiers, not models. The listener loads fresh state itself.
  • Listeners that must not run before the transaction commits are marked that way, with ShouldDispatchAfterCommit on the event or $afterCommit = true on the queued listener.

Strategy

Strategy separates choosing an algorithm from using it. A shipping calculator with flat-rate, weight-based and zone-based pricing is the textbook case.

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

Pick the strategy once, at the edge of the operation. When the selection happens inside a loop over items, it gets harder to follow, because the same question is asked for every item. With two variants and no third in sight, a match expression is shorter and just as clear.

Command and Template Method

A command is a serializable description of an intention, ChargeCustomer(customerId: 42, amountMinor: 1999). Because it's data, it can be queued, delayed, retried, logged and replayed. A queued Laravel job is a command that usually carries its own handle() method, and a separate handler can be attached through Bus::map. Most of the design work goes into the payload: keep it to identifiers and scalars, and make the handler idempotent, since a queue with retries can deliver the same command more than once.

Template Method is for two classes that share a fixed sequence of steps and differ in one, say a report built identically and exported as CSV or PDF. The base class defines the sequence and declares the varying step abstract. That inheritance is enough while only one step differs. Once more steps vary, or variants need combining, switch to composition and inject the exporter as a strategy.

Patterns that rarely fit application code

Interpreter means representing the grammar of your own language and evaluating its tree, and in practice you also need a parser. Check first whether an existing expression engine covers the need. Symfony ExpressionLanguage handles most business-rule cases, pricing conditions or feature-flag predicates, with far less code to maintain.

Mediator is a central object coordinating components that would otherwise reference each other. With an event dispatcher and a container in the framework, the dispatcher already plays that role most of the time. A custom one makes sense when the coordination logic itself is complex and stateful, as in a UI form where fields affect each other.

Flyweight shares immutable state between many small objects to save memory. In PHP, objects created for a request are released when it ends, and a single request rarely creates enough of them to matter. Long-running CLI jobs that create huge numbers of identical value objects, like tokens in a parser, are the exception.

Before approving the PR

  • Can the author name the change the pattern makes cheaper, and is that change actually planned?
  • Does the new interface have a second implementation, or at least a test double that needs it?
  • Is the adapter only translating?
  • Would a match expression or a plain function do the same with less code?
end of node