PHP Design Patterns Every Developer Should Know
By Pawan Singh · Jun 2026
Design patterns are named solutions to recurring problems, not a checklist to force into every project. The ones below earn their keep in real PHP codebases; each includes when to actually reach for it, not just how.
Singleton
Guarantees exactly one instance of a class exists application-wide, accessed through a static method:
class Config
{
private static ?self $instance = null;
private array $data = [];
private function __construct() {}
public static function getInstance(): self
{
return self::$instance ??= new self();
}
}
Use it sparingly. Singletons are effectively global state, which makes testing harder (state leaks between tests unless explicitly reset) and hides a class's real dependencies. In a modern Laravel or Symfony app, a properly configured service container's default "shared" binding gives you the same one-instance guarantee without the global-access downsides — reach for the container before reaching for this pattern by hand.
Factory
Centralizes object creation so calling code doesn't need to know which concrete class to instantiate:
interface PaymentGateway { public function charge(int $cents): void; }
class PaymentGatewayFactory
{
public static function make(string $provider): PaymentGateway
{
return match ($provider) {
'stripe' => new StripeGateway(),
'paypal' => new PayPalGateway(),
default => throw new InvalidArgumentException("Unknown provider: $provider"),
};
}
}
This is genuinely useful whenever the concrete class to build depends on runtime data (a config value, a user's selection) rather than being known at the call site.
Strategy
Swaps out an algorithm's implementation at runtime by depending on an interface rather than a concrete class:
interface DiscountStrategy { public function apply(float $price): float; }
class PercentageDiscount implements DiscountStrategy
{
public function __construct(private float $percent) {}
public function apply(float $price): float { return $price * (1 - $this->percent); }
}
class Order
{
public function __construct(private DiscountStrategy $discount) {}
public function total(float $price): float { return $this->discount->apply($price); }
}
This is the pattern behind most "pluggable behavior" in well-designed apps — new discount types, new export formats, new notification channels — each a class implementing a shared interface, injected rather than branched on with a growing if/match chain.
Repository
Wraps data access behind an interface so the rest of the application doesn't depend directly on Eloquent, Doctrine, or raw SQL:
interface UserRepository
{
public function find(int $id): ?User;
public function save(User $user): void;
}
class EloquentUserRepository implements UserRepository
{
public function find(int $id): ?User { return User::find($id); }
public function save(User $user): void { $user->save(); }
}
The payoff is testability — swap in an in-memory fake implementation in tests without touching a database — and a seam for swapping persistence layers later without rewriting business logic. In small apps built entirely around one ORM with no plans to change that, this can be over-engineering; it earns its cost once you have real, non-trivial query logic worth isolating.
Observer
Lets other parts of the system react to an event without the class that raised it knowing who's listening:
class UserRegistered
{
public function __construct(public User $user) {}
}
// elsewhere, registered once at boot
Event::listen(UserRegistered::class, SendWelcomeEmail::class);
Event::listen(UserRegistered::class, ProvisionDefaultSettings::class);
Laravel's event system (and Symfony's EventDispatcher) is this pattern, built in — you rarely need to hand-roll it, but understanding the underlying idea explains why decoupling "a user registered" from "here's everything that should happen as a result" keeps a register() method from growing into an unreadable list of unrelated side effects.
Decorator
Adds behavior to an object by wrapping it in another object implementing the same interface, rather than modifying the original class or subclassing it:
interface Logger { public function log(string $message): void; }
class TimestampedLogger implements Logger
{
public function __construct(private Logger $inner) {}
public function log(string $message): void
{
$this->inner->log('[' . date('c') . '] ' . $message);
}
}
Useful for cross-cutting behavior — logging, caching, retries — layered onto an existing implementation without touching its source.
Picking the Right One
The failure mode to avoid isn't ignorance of these patterns — it's applying them where a plain function or a simple class would do. Reach for a pattern when it removes real duplication or a real coupling problem you're already feeling, not preemptively because a codebase "should" look sophisticated.
Untangling an over- or under-engineered PHP codebase? Get in touch — I work as a freelance PHP developer.