"The experience of an engineer isn't how much code they write — it's how much code they prevent from breaking."

Software engineering is not about making code work — it's about making it last. As systems grow, scale, and mutate, so does their entropy. Without structure, every new feature risks becoming a Jenga block in a shaky tower.

Design patterns are our antidote. They're not rules — they're reusable thought frameworks. The wisdom of generations of engineers distilled into reusable mental models.

PHP 8.4: Why Patterns Matter More Than Ever

PHP 8.4 represents one of the most expressive and safe eras of the language — a far cry from its scripting roots. It gives engineers the ability to design systems that are not just functional but architecturally elegant.

Some key features that make design patterns more powerful in PHP 8.4:

  • Readonly classes — Immutable services and configuration objects are now native, making Singleton and Strategy safer.
  • Property hooks — Fine-grained control for side effects, perfect for Observers and Decorators.
  • Improved intersection and union types — More robust pattern contracts for Strategy and Factory.
  • Enhanced attributes and reflection — Declarative configuration for factories, repositories, and dependency injection.
  • JIT improvements — Making complex, layered architecture less costly at runtime.

1. Singleton Pattern — The Protector of Resources

The Story

Every large system has a few "protectors" — objects that must exist only once across the runtime. Think of your database connection, logger, or configuration loader. Creating multiple instances could waste resources or create data inconsistency.

That's where the Singleton pattern steps in: it ensures that a class has exactly one instance and provides a global access point to it.

Example — PHP 8.4 Singleton with Readonly Class

readonly class Database {
    private static ?self $instance = null;
    private PDO $connection;
    private function __construct() {
        $this->connection = new PDO(
            'mysql:host=localhost;dbname=ecommerce',
            'root',
            ''
        );
    }
    public static function getInstance(): self {
        return self::$instance ??= new self();
    }
    public function getConnection(): PDO {
        return $this->connection;
    }
}

// Usage
$db = Database::getInstance()->getConnection();

Real-World Use Cases

  • Database connections — Shared PDO or Redis clients across services.
  • Configuration loader — Central access to .env or YAML configs.
  • Logger — Single point of logging for the whole application.
  • Service registry — Managing application-wide constants or environment contexts.
  • Feature flags — Global access to toggles during runtime.
  • Cache pool manager — Unified Memcached or Redis connection pools.
  • Monitoring client — One Prometheus or Sentry client shared system-wide.

Some Insights

Singletons are like global variables in a tuxedo — elegant but potentially dangerous.

✅ Use them only when one instance truly makes sense. ❌ Don't use them to avoid proper dependency injection.

Frameworks like Laravel and Symfony already manage singletons under the hood — their service containers ensure shared instances. Use that instead of rolling your own in production-scale apps.

2. Factory Pattern — Decoupling Creation from Use

The Story

Imagine you're building a notification system. Sometimes you send emails, sometimes SMS, sometimes push notifications. Hardcoding every new channel's logic quickly becomes a nightmare.

The Factory pattern abstracts the creation logic — letting you ask for what you need, without caring about how it's made.

It's like ordering coffee — you don't need to know how the barista grinds the beans; you just ask for a cappuccino.

Example — Factory with PHP 8.4 match Expression and Attributes

interface Notification {
    public function send(string $message): void;
}

#[NotificationType('email')]
class EmailNotification implements Notification {
    public function send(string $message): void {
        echo "Email sent: $message";
    }
}
#[NotificationType('sms')]
class SmsNotification implements Notification {
    public function send(string $message): void {
        echo "SMS sent: $message";
    }
}
class NotificationFactory {
    public static function create(string $type): Notification {
        return match ($type) {
            'email' => new EmailNotification(),
            'sms' => new SmsNotification(),
            default => throw new InvalidArgumentException("Unsupported notification type: $type")
        };
    }
}

Real-World Use Cases

  • Notification systems — Email, SMS, Slack, WhatsApp, Push.
  • Payment gateways — PayPal, Stripe, Apple Pay, Crypto.
  • File storage adapters — Local disk, AWS S3, Google Cloud Storage.
  • Document exporters — PDF, CSV, XLSX, JSON.
  • Auth strategies — JWT, OAuth2, API tokens, SSO.
  • Queue drivers — RabbitMQ, Kafka, Amazon SQS.
  • AI integrations — OpenAI, Claude, Gemini — dynamically selected at runtime.

Some Insights

Factories embody the Open/Closed Principle — open for extension, closed for modification.

When business logic changes, you extend the factory, not rewrite it.

Pair factories with Dependency Injection Containers. In Laravel, you might register a factory as a singleton service; in Symfony, as a factory definition in services.yaml.

This keeps creation logic isolated and your services blissfully unaware of construction details.

3. Strategy Pattern — Swappable Algorithms

The Story

"We need to support multiple payment methods." "We're adding a new discount rule for VIP users." "We're testing two recommendation algorithms."

These are classic "Strategy pattern" moments — when behavior must be interchangeable at runtime without rewriting core logic.

The Strategy pattern encapsulates algorithms or business rules into separate classes. You can swap them like Lego blocks — cleanly, dynamically, safely.

Example — PHP 8.4 Strategy with Readonly Properties

interface PaymentStrategy {
    public function pay(float $amount): void;
}

readonly class PaypalStrategy implements PaymentStrategy {
    public function pay(float $amount): void {
        echo "Paid via PayPal: $$amount\n";
    }
}
readonly class StripeStrategy implements PaymentStrategy {
    public function pay(float $amount): void {
        echo "Paid via Stripe: $$amount\n";
    }
}
class PaymentContext {
    public function __construct(private PaymentStrategy $strategy) {}
    public function execute(float $amount): void {
        $this->strategy->pay($amount);
    }
}

Real-World Use Cases

  • Payment systems — PayPal, Stripe, Crypto, Wire transfers.
  • Discount and tax calculation engines — different algorithms for regions or promotions.
  • AI model selection — switch between providers based on performance.
  • Content recommendation systems — different ranking strategies.
  • Authentication flows — local login, OAuth, SAML, or passwordless.
  • Shipping rate calculators — UPS, DHL, FedEx, regional logistics.
  • Search algorithms — fuzzy match, vector search, keyword relevance.

Some Insights

The Strategy pattern is the antidote to if-else.

Instead of a giant conditional that decides which algorithm to run, each strategy encapsulates one behavior.

Bonus tip: Combine Strategy with Factory — dynamically choose and build strategies from config, user settings, or environment.

In Laravel, you can register strategies as tagged services and resolve them via container lookups. In Symfony, use ServiceLocator for the same purpose.

4. Observer Pattern — The Event Whisperer

The Story

Modern systems are alive with events. A user signs up — send a welcome email, update metrics, alert admins, trigger onboarding flows.

Without structure, one method call can become a spaghetti mess of cross-dependencies. The Observer pattern solves this: publishers emit events, and subscribers react to them — without knowing about each other.

It's loose coupling in action. The backbone of event-driven systems.

Example — PHP 8.4 Observer with Property Hooks

interface Observer {
    public function update(string $event, mixed $data = null): void;
}

class EventManager {
    private array $observers = [];
    public function attach(string $event, Observer $observer): void {
        $this->observers[$event][] = $observer;
    }
    public function notify(string $event, mixed $data = null): void {
        foreach ($this->observers[$event] ?? [] as $observer) {
            $observer->update($event, $data);
        }
    }
}
class UserAnalytics implements Observer {
    public function update(string $event, mixed $data = null): void {
        echo "Analytics updated for event: $event\n";
    }
}
class EmailNotifier implements Observer {
    public function update(string $event, mixed $data = null): void {
        echo "Email sent for event: $event\n";
    }
}

Real-World Use Cases

  • Laravel Events and Listeners — classic observer pattern under the hood.
  • Symfony EventDispatcher — same concept, framework-integrated.
  • E-commerce — order placed → notify, charge, log, update stock.
  • Microservices — publish events to RabbitMQ, Kafka, or Redis Streams.
  • Analytics pipelines — user actions triggering metrics asynchronously.
  • IoT applications — devices broadcasting events to handlers.
  • Logging frameworks — log hooks firing on system-level events.

Some Insights

Observer decouples cause and effect. It's how you build scalable systems where events flow naturally and dependencies don't tangle.

Pro tip: Offload heavy observers to message queues or jobs. Don't send emails or write logs inline with user actions — that's how you end up with sluggish APIs.

Use Laravel's event broadcasting, Symfony Messenger, or AWS SNS for distributed observers.

Wrapping Up Part 1

In this first part, we explored the core four patterns that every senior PHP developer uses daily — often unconsciously:

  • Singleton: The protector of shared state.
  • Factory: The gatekeeper of creation.
  • Strategy: The chameleon of behavior.
  • Observer: The orchestra of events.

These patterns don't just solve problems — they prevent future chaos.

In Part 2, we'll go deeper into architectural patterns that shape system design: Decorator, Repository, and Dependency Injection — plus a bonus deep dive on how to combine patterns for real-world power.

Let's Share and Learn Together

Design patterns are not just about code — they're about the collective understanding and experience of engineers everywhere.

I'd be genuinely grateful to hear your thoughts:

  • Which of these patterns have you found most useful in your PHP projects?
  • Do you have any personal tips, stories, or alternative approaches to share?
  • How do you combine these patterns in real-world applications?

Your insights and experiences are valuable — sharing them helps all of us write more elegant, maintainable, and resilient systems.

Thank you for taking the time to read and reflect. I truly look forward to your thoughts and comments! 🌟

✍️ About the Author

Mathews Jose is a Senior Software Engineer with over a decade of experience specializing in PHP, REST APIs, and event-driven architectures. He's passionate about building scalable backend systems and exploring how emerging technologies like AI can seamlessly integrate into modern software architecture.

🔗 Connect with Mathews on LinkedIn