Design Patterns
Design patterns are reusable solutions to problems that appear repeatedly in software development. They are not copy-paste code snippets — they are blueprints you adapt to your situation. Learning them gives you a shared vocabulary with other developers and a toolkit for writing code that is easier to extend and maintain.
This lesson covers four patterns you will encounter constantly in PHP projects: Singleton, Factory, Observer, and Strategy. Each one solves a distinct problem, and knowing when to reach for them is just as important as knowing how to implement them.
Singleton: One Instance, Global Access
The Singleton pattern ensures a class has exactly one instance and provides a global access point to it. The classic use case is a database connection — you want one connection pool shared across the application, not a new connection every time you query.
<?php
class Database {
private static ?Database $instance = null;
private int $queryCount = 0;
private function __construct() {
// Private constructor prevents direct instantiation
echo "Database connection established.\n";
}
public static function getInstance(): Database {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function query(string $sql): string {
$this->queryCount++;
return "Result of: {$sql} (query #{$this->queryCount})";
}
public function getQueryCount(): int {
return $this->queryCount;
}
}
$db1 = Database::getInstance();
echo $db1->query("SELECT * FROM users") . "\n";
$db2 = Database::getInstance();
echo $db2->query("SELECT * FROM posts") . "\n";
// Both variables point to the same instance
echo "Same instance: " . ($db1 === $db2 ? 'yes' : 'no') . "\n";
echo "Total queries: " . $db1->getQueryCount() . "\n";
Notice that "Database connection established" prints only once even though we called getInstance() twice. The second call returns the existing instance. Be careful with Singleton — overusing it creates hidden global state that makes testing harder. Use it only when a single shared instance is genuinely required.
Factory: Delegate Object Creation
The Factory pattern separates the code that uses an object from the code that creates it. Instead of scattering new calls throughout your codebase, you centralise creation logic in one place. When the creation rules change, you update one method — not twenty call sites.
<?php
interface Logger {
public function log(string $message): void;
}
class FileLogger implements Logger {
public function log(string $message): void {
echo "[FILE] {$message}\n";
}
}
class ConsoleLogger implements Logger {
public function log(string $message): void {
echo "[CONSOLE] {$message}\n";
}
}
class SlackLogger implements Logger {
public function log(string $message): void {
echo "[SLACK] Posting to #alerts: {$message}\n";
}
}
class LoggerFactory {
public static function create(string $type): Logger {
return match ($type) {
'file' => new FileLogger(),
'console' => new ConsoleLogger(),
'slack' => new SlackLogger(),
default => throw new InvalidArgumentException("Unknown logger type: {$type}"),
};
}
}
$env = 'console'; // In a real app this comes from config
$logger = LoggerFactory::create($env);
$logger->log("User login failed for alice@example.com");
$fileLogger = LoggerFactory::create('file');
$fileLogger->log("Payment processed: order #8821");
$slackLogger = LoggerFactory::create('slack');
$slackLogger->log("Deployment completed successfully");
The calling code never uses new directly — it asks the factory for the right kind of logger. Adding a new logger type means adding one case and one class, nothing else changes.
Observer: React to Events Without Tight Coupling
The Observer pattern lets objects subscribe to events and get notified automatically when something happens. The subject (the thing that changes) knows nothing about what its observers do — it just calls notify() and moves on. This is the backbone of event systems, hooks, and reactive programming.
<?php
interface Observer {
public function update(string $event, mixed $data): void;
}
class EventEmitter {
private array $listeners = [];
public function on(string $event, Observer $observer): void {
$this->listeners[$event][] = $observer;
}
protected function emit(string $event, mixed $data = null): void {
foreach ($this->listeners[$event] ?? [] as $observer) {
$observer->update($event, $data);
}
}
}
class UserService extends EventEmitter {
public function register(string $email): void {
// Simulate saving the user
echo "User registered: {$email}\n";
$this->emit('user.registered', ['email' => $email]);
}
}
class WelcomeEmailObserver implements Observer {
public function update(string $event, mixed $data): void {
echo "Sending welcome email to {$data['email']}\n";
}
}
class AuditLogObserver implements Observer {
public function update(string $event, mixed $data): void {
echo "Audit log: {$event} triggered for {$data['email']}\n";
}
}
class SlackNotificationObserver implements Observer {
public function update(string $event, mixed $data): void {
echo "Slack: New signup — {$data['email']}\n";
}
}
$userService = new UserService();
$userService->on('user.registered', new WelcomeEmailObserver());
$userService->on('user.registered', new AuditLogObserver());
$userService->on('user.registered', new SlackNotificationObserver());
$userService->register('bob@example.com');
UserService is completely unaware of emails, audit logs, or Slack. You can add or remove observers without touching UserService at all. PHP's SPL library includes SplSubject and SplObserver interfaces if you prefer a built-in contract.
Strategy: Swap Algorithms at Runtime
The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. The object using the strategy does not need to know which algorithm it is running — it just delegates to whatever strategy was injected.
A billing system is a good example: different customers may pay with different methods, but the checkout process is the same regardless.
<?php
interface PaymentStrategy {
public function pay(float $amount): string;
}
class CreditCardPayment implements PaymentStrategy {
public function __construct(private string $cardNumber) {}
public function pay(float $amount): string {
$masked = '****' . substr($this->cardNumber, -4);
return "Charged \${$amount} to card {$masked}";
}
}
class PayPalPayment implements PaymentStrategy {
public function __construct(private string $email) {}
public function pay(float $amount): string {
return "Sent \${$amount} via PayPal to {$this->email}";
}
}
class CryptoPayment implements PaymentStrategy {
public function __construct(private string $walletAddress) {}
public function pay(float $amount): string {
return "Transferred \${$amount} in BTC to {$this->walletAddress}";
}
}
class Checkout {
public function __construct(private PaymentStrategy $strategy) {}
public function setStrategy(PaymentStrategy $strategy): void {
$this->strategy = $strategy;
}
public function complete(float $amount): void {
echo $this->strategy->pay($amount) . "\n";
}
}
$checkout = new Checkout(new CreditCardPayment('4111111111111234'));
$checkout->complete(49.99);
$checkout->setStrategy(new PayPalPayment('alice@example.com'));
$checkout->complete(120.00);
$checkout->setStrategy(new CryptoPayment('1A2b3C4d5E6f'));
$checkout->complete(299.00);
Checkout works the same way regardless of which payment method is active. Adding a new payment provider means creating one new class that implements PaymentStrategy — no changes to Checkout or to existing strategies.
Try It Yourself
Combine the Factory and Strategy patterns: build a ReportExporter that produces reports in different formats (CSV, JSON, HTML). Use a factory to create the right exporter based on a format string, and a strategy interface to define the export contract.
<?php
interface ExportStrategy {
public function export(array $data): string;
}
class CsvExport implements ExportStrategy {
public function export(array $data): string {
$rows = [implode(',', array_keys($data[0]))];
foreach ($data as $row) {
$rows[] = implode(',', array_values($row));
}
return implode("\n", $rows);
}
}
class JsonExport implements ExportStrategy {
public function export(array $data): string {
return json_encode($data, JSON_PRETTY_PRINT);
}
}
class HtmlExport implements ExportStrategy {
public function export(array $data): string {
$rows = '';
$headers = array_keys($data[0]);
$ths = implode('', array_map(fn($h) => "<th>{$h}</th>", $headers));
foreach ($data as $row) {
$tds = implode('', array_map(fn($v) => "<td>{$v}</td>", array_values($row)));
$rows .= "<tr>{$tds}</tr>";
}
return "<table><thead><tr>{$ths}</tr></thead><tbody>{$rows}</tbody></table>";
}
}
class ExporterFactory {
public static function create(string $format): ExportStrategy {
return match ($format) {
'csv' => new CsvExport(),
'json' => new JsonExport(),
'html' => new HtmlExport(),
default => throw new InvalidArgumentException("Unsupported format: {$format}"),
};
}
}
$data = [
['id' => 1, 'name' => 'Alice', 'score' => 95],
['id' => 2, 'name' => 'Bob', 'score' => 88],
['id' => 3, 'name' => 'Carol', 'score' => 72],
];
foreach (['csv', 'json', 'html'] as $format) {
$exporter = ExporterFactory::create($format);
echo strtoupper($format) . ":\n";
echo $exporter->export($data) . "\n\n";
}
Try adding an XmlExport class and registering it in the factory without touching any of the existing classes.
Key Takeaways
- Singleton guarantees one shared instance — useful for connections and registries, but avoid it as a substitute for proper dependency injection
- Factory centralises object creation so callers never need to know which concrete class they are getting
- Observer decouples event producers from event consumers — adding a new reaction never requires modifying the source
- Strategy makes algorithms interchangeable at runtime without conditional branching in the calling code
- All four patterns favour composition and interfaces over inheritance — prefer "has a" over "is a" relationships
- Patterns are tools, not goals — apply them when they reduce complexity, not to demonstrate familiarity with the concept
Pro Tip: The real value of design patterns is not the implementation — it is the shared language. When you say "use the Strategy pattern here", every developer on your team immediately understands the structure, the intent, and the extension points. That clarity alone is worth the abstraction cost.
Next Steps
You now understand Singleton, Factory, Observer, and Strategy patterns -- architectural tools that keep complex PHP applications flexible and maintainable.
Patterns give you structure, but applications live and die by the data users send them. HTML forms are the primary way users interact with web applications, and every form submission is untrusted input that needs careful handling. Form processing teaches you to safely read, sanitize, and validate user input -- the practical intersection of security and usability that every web developer must master.