php/pattern-reference@v1.0.0
article··15 min read

Design patterns in production: what they solve, what they cost, and when to skip them

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

The GoF book was published in 1994. In the thirty years since, design patterns have gone through at least three complete cycles: introduction, over-application, backlash, and cautious re-adoption. We are somewhere in the fourth or fifth cycle now, depending on which part of the industry you are in.

My view, formed by reading a lot of codebases that apply patterns and a lot that do not: patterns are not solutions. They are a shared vocabulary for naming the shape of a problem you have already solved. The value is not in the implementation. It is in the naming, because naming what you have built is how you communicate it to the next engineer.

What follows is a production-oriented field guide. Not every pattern (the twenty-three canonical ones plus however many the community has added since) but the ones I reach for regularly, the ones I see misapplied most often, and the ones I have never found a genuine use for in application-layer PHP.

Creational patterns: object construction is harder than it looks

Singleton aged worst of all the creational patterns. It is valid exactly when you have immutable configuration that is expensive to load and needs to be shared within a single process. Invalid in almost every other case. Covered in depth elsewhere in this series.

Factory Method is the one I reach for most often in this group. It becomes necessary when the type of object you need is a runtime decision, payment gateway selection, notification channel, document parser for an unknown file type. The factory does not build objects; it delegates construction to the DI container and returns an interface. Covered in depth in this series.

Builder is underused for complex domain objects and overused for query construction. A QueryBuilder is a legitimate Builder: it accumulates conditions, then produces an immutable query object. A UserBuilder that exists only to make tests readable (UserBuilder::new()->withName('Alice')->withRole('admin')->build()) is fine in tests, but if you need a builder to construct a User in production code, your User constructor probably does too much.

Abstract Factory is rarely necessary in application code. Where I have seen it used correctly: a UI component library that needs to swap between a light and dark theme, producing consistent button, input, and modal components without the caller knowing which theme is active. In backend systems, the DI container typically replaces the need for abstract factories.

Prototype is one I have needed deliberately exactly once in ten years of production PHP. It was for a document template system where copying a template's structure was expensive enough to justify a dedicated clone interface. For everything else, clone works directly. If you are creating a Prototype interface with a copy() method that just calls clone $this, you have added indirection for no benefit.

Structural patterns: the ones that pay rent

Adapter has the highest value of any structural pattern in application code. Every third-party integration you write is an adapter: it takes the external system's interface and translates it into your domain's interface. The discipline is keeping the adapter thin. If your Stripe adapter contains business logic about when to retry or how to calculate fees, it is not an adapter. It is a service that happens to call Stripe.

// Thin adapter: translates types, nothing more
final class StripePaymentGateway implements PaymentGatewayInterface
{
    public function __construct(private readonly \Stripe\StripeClient $stripe) {}

    public function charge(Money $amount, string $currency): ChargeResult
    {
        try {
            $intent = $this->stripe->paymentIntents->create([
                'amount'   => $amount->getAmount(),   // Stripe wants cents
                'currency' => strtolower($currency),
            ]);
            return new ChargeResult(chargeId: $intent->id, status: ChargeStatus::Pending);
        } catch (\Stripe\Exception\CardException $e) {
            return new ChargeResult(chargeId: null, status: ChargeStatus::Declined, error: $e->getMessage());
        }
    }
}

Decorator is the structural pattern I see over-engineered most often. A decorator adds behaviour to an object without changing its interface. The canonical PHP use case: caching decorators, logging decorators, rate-limiting decorators. These are powerful and correct. What I see instead: decorator chains seven levels deep, where debugging requires understanding which decorator is active in which context and why.

// Correct use: transparent caching
final class CachingUserRepository implements UserRepositoryInterface
{
    public function __construct(
        private readonly UserRepositoryInterface $inner,
        private readonly CacheInterface $cache,
        private readonly int $ttl = 300,
    ) {}

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

The test for the caching decorator verifies that it calls inner on a cache miss and skips inner on a hit. The test for the database repository verifies data access. They are independently testable, independently deployable.

Facade is overused as a band-aid. A facade simplifies a complex subsystem behind a single interface. Laravel's static facades (DB::table('users')) are an opinionated implementation of this. The correct use: when a subsystem has ten classes and the caller needs to interact with only two or three operations from it. The misuse: wrapping a single class behind a facade to avoid injecting it.

Proxy is most commonly encountered in PHP via lazy-loading proxy generators (Doctrine, Symfony). Building your own proxy is rare and usually wrong. If you need to intercept method calls for logging, caching, or access control, a decorator is almost always the better tool because it is explicit. A proxy that intercepts calls transparently is harder to test and harder to reason about.

Behavioural patterns: where most of the real design work happens

Observer / Event scales most cleanly in modern PHP, because every framework has an event dispatcher. When Order transitions to Paid, it dispatches OrderPaid. The email listener, the inventory listener, and the analytics listener all subscribe independently. Adding a fourth listener requires no changes to Order or to the other three listeners.

The failure mode: event listeners with cascading effects and no circuit breaker. I have seen a chain where OrderPaidReserveInventoryInventoryLowSendSupplierEmailEmailDeliveryFailedCreateAlertTaskAlertTaskCreated → five more listeners. The original OrderPaid event triggered 47 database queries across 9 listener classes. Each was individually reasonable. Together, they made every order completion take 800ms.

Strategy most cleanly separates "what to do" from "how to do it." A shipping cost calculator that picks between flat-rate, weight-based, and zone-based strategies based on the carrier is using Strategy correctly. The strategy selection should happen once per request, not inside the hot path of the calculation.

Command underlies every modern queue system. A ChargeCustomerCommand is a serialisable, self-contained description of an intention. It does not execute anything, it describes what should be executed. The command bus picks it up and dispatches it to the handler. Commands can be delayed, retried, audited, and replayed in ways that a direct method call cannot.

Template Method is the pattern I use most often without realising it. If you have two classes that share 90% of their logic and differ in one step (a report generator that formats identically but exports to CSV or PDF) the base class implements the shared structure and the subclass overrides the differing step. The caution: inheritance for code sharing is fine; inheritance for polymorphism where composition would work is not.

The ones I have not found a genuine use for

Interpreter means building a parser and evaluator for a custom language in application-layer PHP. I have seen it once, in a rules engine for a pricing system. It was the right tool there. In the twenty other times I have seen it proposed, a simpler expression evaluator or Symfony's ExpressionLanguage would have been less code and more maintainable.

Mediator is often described as "Observer but the observers know about each other through a central broker." The added complexity over a standard event dispatcher has not been justified in any system I have worked on.

Flyweight is a memory optimisation for large numbers of fine-grained objects. PHP's memory model (request-scoped, process-isolated) makes this rarely necessary. Where I have seen it used correctly: a parser that creates thousands of token objects and caches identical tokens by value.

The question I ask before applying any pattern

What does this pattern make easier to change?

A Decorator makes it easier to add or remove cross-cutting behaviour (caching, logging) without modifying the decorated class. A Strategy makes it easier to add a new algorithm without modifying the context. An Adapter makes it easier to swap an external dependency.

If the answer is "I am not sure what it makes easier to change, I just think it is a good architecture," the pattern is probably solving a problem you do not have yet. The overhead of the abstraction is real and immediate. The benefit is hypothetical. Pay the overhead when the benefit is also real.

end of node