PHP Design Patterns

The patterns every PHP dev should recognize: Singleton, Factory, Strategy, Repository, Observer, Decorator, Adapter, and Dependency Injection - each with a working PHP 8 example.

Advanced 11 min read 10 examples

Why Design Patterns?

Design patterns are names for recurring solutions. They give your team shared vocabulary ("let's wrap this in a Decorator"), and they encode hard-won lessons about flexibility, testability, and change.

Singleton

Ensures only one instance of a class exists. Use sparingly - it's the most misused pattern.

PHP
<?php
class Config {
    private static ?self $instance = null;
    private array $data;

    private function __construct() {
        $this->data = parse_ini_file("config.ini");
    }

    public static function instance(): self {
        return self::$instance ??= new self();
    }

    public function get(string $key): mixed {
        return $this->data[$key] ?? null;
    }
}

$dsn = Config::instance()->get("db_dsn");
Prefer DI over Singleton

A container can give you a single shared instance without the hidden global. Singleton makes testing painful - you can't swap the dependency for a fake.

Factory & Factory Method

Encapsulates object construction logic. Hides the new from callers.

PHP
<?php
interface Notifier {
    public function send(string $to, string $msg): void;
}

class EmailNotifier implements Notifier { /* ... */ }
class SmsNotifier   implements Notifier { /* ... */ }
class SlackNotifier implements Notifier { /* ... */ }

class NotifierFactory {
    public static function make(string $type): Notifier {
        return match ($type) {
            "email" => new EmailNotifier(),
            "sms"   => new SmsNotifier(),
            "slack" => new SlackNotifier(),
            default => throw new InvalidArgumentException("Unknown: $type"),
        };
    }
}

$n = NotifierFactory::make("email");
$n->send("user@example.com", "Hello");

Strategy

Encapsulates an algorithm so you can swap it at runtime.

PHP
<?php
interface PricingStrategy {
    public function calculate(float $base, int $qty): float;
}

class FlatPricing implements PricingStrategy {
    public function calculate(float $base, int $qty): float {
        return $base * $qty;
    }
}

class BulkDiscountPricing implements PricingStrategy {
    public function calculate(float $base, int $qty): float {
        $discount = $qty >= 10 ? 0.10 : 0;
        return $base * $qty * (1 - $discount);
    }
}

class Order {
    public function __construct(private PricingStrategy $pricing) {}
    public function total(float $base, int $qty): float {
        return $this->pricing->calculate($base, $qty);
    }
}

// Swap strategies freely
$order = new Order(new BulkDiscountPricing());

Repository

Mediates between your domain code and the database. Already shown in the CRUD tutorial - the killer pattern for testable data access.

Observer / Pub-Sub

PHP
<?php
class EventBus {
    /** @var array<string, callable[]> */
    private array $listeners = [];

    public function on(string $event, callable $cb): void {
        $this->listeners[$event][] = $cb;
    }

    public function emit(string $event, mixed ...$args): void {
        foreach ($this->listeners[$event] ?? [] as $cb) {
            $cb(...$args);
        }
    }
}

$bus = new EventBus();
$bus->on("user.registered", fn($u) => sendWelcomeEmail($u));
$bus->on("user.registered", fn($u) => logger()->info("New user: {$u->email}"));

// later...
$bus->emit("user.registered", $newUser);

Decorator

Wraps an object to add behavior without changing the original. Common in middleware / caching layers.

PHP
<?php
interface UserRepo {
    public function find(int $id): ?User;
}

class DbUserRepo implements UserRepo {
    public function find(int $id): ?User { /* hits database */ }
}

class CachingUserRepo implements UserRepo {
    public function __construct(
        private UserRepo $inner,
        private CacheInterface $cache,
    ) {}

    public function find(int $id): ?User {
        return $this->cache->get("user_$id",
            fn() => $this->inner->find($id),
            ttl: 60
        );
    }
}

// Compose
$repo = new CachingUserRepo(new DbUserRepo($pdo), $cache);

Adapter

Wraps a class with an incompatible interface so it fits where your code expects another.

PHP
<?php
// Our app uses PSR-3 LoggerInterface
interface LoggerInterface {
    public function log(string $level, string $msg): void;
}

// Old vendor SDK speaks differently
class LegacySdkLogger {
    public function writeMessage(int $severity, string $text) { /* ... */ }
}

// Adapter bridges the gap
class LegacyLoggerAdapter implements LoggerInterface {
    public function __construct(private LegacySdkLogger $sdk) {}
    public function log(string $level, string $msg): void {
        $sev = match($level) {
            "error" => 1, "warning" => 2, default => 3,
        };
        $this->sdk->writeMessage($sev, $msg);
    }
}

Dependency Injection

The most important pattern of all. Pass dependencies in rather than constructing them inside:

PHP
<?php
// BAD - tightly coupled, untestable
class OrderService {
    public function place(array $data) {
        $pdo = new PDO("mysql:...");           // hidden dep
        $mailer = new SmtpMailer();            // hidden dep
        // ...
    }
}

// GOOD - injected, swappable, testable
class OrderService {
    public function __construct(
        private PDO $db,
        private MailerInterface $mailer,
    ) {}

    public function place(array $data) {
        // use $this->db, $this->mailer
    }
}

// Wiring at the boundary (a DI container can automate this)
$service = new OrderService($pdo, new SmtpMailer());

// In tests
$service = new OrderService($fakePdo, new InMemoryMailer());

When NOT to Use Patterns

  • For a 50-line script - direct code wins.
  • When the language has a built-in - PHP closures replace some Strategy uses; Stringable replaces a Decorator chain for output.
  • When you're guessing at future flexibility - YAGNI ("You Aren't Gonna Need It"). Add the abstraction the second time you need it.
  • To impress code reviewers - simple wins.
Recognize, don't recite

Knowing the names of patterns is more valuable than memorising the textbook implementations. When a problem feels familiar, naming the pattern unlocks the right vocabulary to discuss it with teammates.

Next Steps

Frequently Asked Questions

The concepts are timeless. The literal Gang of Four code from 1994 sometimes feels heavy in modern PHP because language features (closures, traits, attributes) provide built-in solutions. Use patterns as vocabulary - "this is a Factory" - more than as templates to copy verbatim.

Often, yes. It's hard to test and hides dependencies. Use it only for genuinely single-instance things (a database connection pool, the PSR logger). For "instantiate this class once", prefer a DI container that gives you a single shared instance.

For a 200-line script, no - just call PDO directly. Repository pays off when you need to swap data sources, mock for tests, or share queries across many places. Add it when you feel the pain, not preemptively.

Strategy lets you swap implementations of the SAME interface (different sorting algorithms). Adapter lets you make a class WITH A DIFFERENT INTERFACE work where your code expects another (wrap a legacy SOAP client to look like a modern PSR-18 HTTP client).

For small projects, no - manual injection (just pass the dependency in the constructor) is fine. For larger apps with many services, a container (PHP-DI, Symfony DI, Laravel's container) wires the graph for you. The principle matters more than the tool.