Contents
PHP design patterns explained
Fellow developers, have you ever stared at a sprawling codebase late into the night, coffee gone cold, wondering why everything feels tangled and brittle? I have. That moment when a simple change ripples through dozens of files, breaking things you didn't even know were connected. PHP design patterns are your quiet saviors in those battles—they're proven blueprints for taming chaos, making code breathe easier, scalable, and joyful to maintain.
I've leaned on them in freelance gigs, agency rushes, and solo side projects. They're not abstract theory; they're tools forged from real pain. Let's walk through them together, category by category, with code snippets you can steal tomorrow and stories from the trenches that hit home.
Why design patterns still matter in PHP today
PHP's evolved—hello, PHP 8.3 with its attributes and readonly properties—but the problems haven't. Dynamic apps, APIs humming under load, legacy migrations. Patterns like Singleton for that one database handle or Factory for spawning objects without mess keep things sane.
Picture this: You're building a Laravel service for an e-commerce client. Orders flood in, payments switch providers. Without Strategy, you're if-else hell. With it? Swap algorithms like outfits. Benefits hit hard:
- Reusability: Write once, deploy everywhere. No copy-paste sins.
- Maintainability: Debug one class, not a web of dependencies.
- Scalability: Frameworks like Symfony and Laravel bake them in—Dependency Injection, Observer. Understand patterns, decode the magic.
But here's the rub—overuse them, and code turns pretentious. Use when the problem screams for it. I've refactored "pattern soup" into plain objects and slept better.
Creational patterns: Birthing objects gracefully
These handle how objects come to life, decoupling creation from usage. Perfect for PHP's object-oriented heart.
Singleton: The lone warrior
Ever needed exactly one instance? Database connections, loggers, config loaders. Singleton enforces it, with global access.
I remember a midnight deploy where multiple PDO instances chewed RAM. Singleton fixed it.
class Database {
private static $instance = null;
private $pdo;
private function __construct() {
$this->pdo = new PDO('mysql:host=localhost;dbname=app', 'user', 'pass');
}
public static function getInstance(): self {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function query(string $sql): PDOStatement {
return $this->pdo->query($sql);
}
}
Use: $db = Database::getInstance();. Caution: Globals can mock-kill tests. Dependency injection often trumps it.
Factory Method: Subclasses decide
Hand creation logic to subclasses. Great for APIs where object types vary.
From a car rental app: Base VehicleFactory with createVehicle($type) overridden per subclass.
abstract class VehicleFactory {
abstract public function createVehicle(): Vehicle;
}
class CarFactory extends VehicleFactory {
public function createVehicle(): Vehicle {
return new Car('Sedan', 4);
}
}
Client code stays clean: $factory->createVehicle(). No if ($type == 'car') nightmares.
Builder: Step-by-step complexity
Complex objects? SQL queries, HTTP clients. Builder chains methods for fluency.
Think user profiles with optional fields. One evening, I built a report generator—no more giant constructors.
class UserBuilder {
private $name;
private $email;
private $role = 'user';
public function setName(string $name): self {
$this->name = $name;
return $this;
}
public function setEmail(string $email): self {
$this->email = $email;
return $this;
}
public function setRole(string $role): self {
$this->role = $role;
return $this;
}
public function build(): User {
return new User($this->name, $this->email, $this->role);
}
}
// Usage: (new UserBuilder())->setName('Alex')->setEmail('alex@example.com')->build();
Pure elegance. Scales to Composer packages or Laravel queries.
Structural patterns: Wiring the architecture
Now that objects exist, how do they connect without knots? Structural patterns compose classes and objects into bigger structures.
Adapter: Bridging incompatibles
Legacy XML API meets your JSON world? Adapter translates interfaces.
Real story: Migrating a client's old SOAP service. Adapter wrapped it seamlessly.
interface PaymentProcessor {
public function charge(float $amount): bool;
}
class StripeProcessor implements PaymentProcessor {
public function charge(float $amount): bool {
// Stripe logic
return true;
}
}
class LegacyPayPal {
public function processPayment($amt) {
// Old API
}
}
class PayPalAdapter implements PaymentProcessor {
private $paypal;
public function __construct(LegacyPayPal $paypal) {
$this->paypal = $paypal;
}
public function charge(float $amount): bool {
$this->paypal->processPayment($amount);
return true;
}
}
Switch processors without client changes. Third-party bliss.
Decorator: Layer on features
Extend behavior dynamically, no subclass explosion. Logging, caching, validation.
Coffee shop sim: Base Coffee, decorators for milk, sugar.
interface Coffee {
public function cost(): int;
}
class SimpleCoffee implements Coffee {
public function cost(): int { return 5; }
}
class MilkDecorator implements Coffee {
private $coffee;
public function __construct(Coffee $coffee) {
$this->coffee = $coffee;
}
public function cost(): int {
return $this->coffee->cost() + 2;
}
}
Stack 'em: new MilkDecorator(new SugarDecorator(new SimpleCoffee())). Flexible toggles in middleware.
Proxy: Gatekeeper control
Lazy-load heavy objects, add logging, access checks. Proxy stands in.
Image loader: Proxy fetches only on demand.
class RealImage {
private $file;
public function __construct(string $file) {
$this->file = $file;
$this->loadFromDisk();
}
private function loadFromDisk() {
// Heavy load
}
}
class ProxyImage {
private $realImage = null;
private $filename;
public function __construct(string $filename) {
$this->filename = $filename;
}
public function display() {
if ($this->realImage === null) {
$this->realImage = new RealImage($this->filename);
}
$this->realImage->display();
}
}
Saves cycles. Caching proxies shine in APIs.
Behavioral patterns: Objects talking smart
Communication without spaghetti. Responsibilities clear, algorithms swappable.
Observer: Event cascades
One change, many reactions. Pub-sub for notifications, Laravel events.
User registers—email, log, analytics fire.
interface Observer {
public function update(string $event, User $user);
}
class User {
private array $observers = [];
public function attach(Observer $observer): void {
$this->observers[] = $observer;
}
public function register(): void {
// Register logic
$this->notify('registered');
}
private function notify(string $event): void {
foreach ($this->observers as $observer) {
$observer->update($event, $this);
}
}
}
class EmailNotifier implements Observer {
public function update(string $event, User $user): void {
if ($event === 'registered') {
// Send email
}
}
}
Decoupled power. Symfony events, anyone?
Strategy: Algorithm families
Swap behaviors at runtime. Sorting, payments, validators.
Payment gateways: Credit card, PayPal, crypto.
interface PaymentStrategy {
public function pay(float $amount): bool;
}
class CreditCardStrategy implements PaymentStrategy {
public function pay(float $amount): bool {
// Charge card
return true;
}
}
class ShoppingCart {
private PaymentStrategy $strategy;
public function setStrategy(PaymentStrategy $strategy): void {
$this->strategy = $strategy;
}
public function checkout(float $amount): bool {
return $this->strategy->pay($amount);
}
}
// Usage: $cart->setStrategy(new CreditCardStrategy()); $cart->checkout(100);
Runtime flexibility. No if-else chains.
MVC: The classic trio
Not strict GoF, but PHP staple. Model (data), View (UI), Controller (logic).
Pure PHP example from a tutorial stuck with me—simple CRUD.
Controller grabs from Model, pushes to View. Scales to Laravel's Eloquent + Blade.
Patterns in the wild: Frameworks and pitfalls
Laravel? Facade (Proxy-ish), Service Container (Factory/DI). Symfony? Dependency Injection everywhere. Spot them, and frameworks demystify.
Pitfalls I've hit:
- Over-engineering: Simple script? Skip Singleton.
- Globals creep: Singleton temptation—inject instead.
- Testability: Mocks love interfaces.
- PSR-12: Patterns shine in clean code.
Combine: Factory + Strategy for pluggable creators.
Friends, next project, pause before the tangle forms. Pick a pattern, code flows freer. That quiet satisfaction when refactoring sings? Patterns deliver it, project after project, leaving you—and your future self—grateful.