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
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");
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
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
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
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
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
// 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
// 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;
Stringablereplaces 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.
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.