"Great architecture isn't about complexity — it's about clarity that survives complexity."

In Part 1, we explored four foundational design patterns that every senior PHP developer should master — Singleton, Factory, Strategy, and Observer. Together, they help control instantiation, organize logic, and manage event-driven flow.

But now we step into deeper waters — the architectural patterns. These are not about individual classes; they're about how entire systems breathe. They govern data access, runtime flexibility, and how you wire your application for long-term maintainability.

With PHP 8.4, design patterns feel more natural than ever. Readonly classes, property hooks, intersection types, and enhanced type safety reduce boilerplate and make patterns safer and more expressive.

Let's continue our journey with three advanced patterns that separate good code from systems that stand the test of time.

5. Decorator Pattern — Dynamic Enhancement

The Story

You've built a reliable service — say, a data exporter that outputs customer reports. Now product asks, "Can we add logging?" Then another voice says, "Add caching to speed it up!" Suddenly, you're staring at conditionals everywhere.

The Decorator pattern solves this by letting you wrap existing behavior dynamically, adding features like layers of an onion — without modifying the core class.

Example — PHP 8.4

interface ReportExporter {
    public function export(array $data): string;
}
class CsvExporter implements ReportExporter {
    public function export(array $data): string {
        return implode(',', $data);
    }
}
class LoggerExporter implements ReportExporter {
    public function __construct(private readonly ReportExporter $exporter) {}
    public function export(array $data): string {
        $result = $this->exporter->export($data);
        echo "[LOG] Exported at " . date('H:i:s') . PHP_EOL;
        return $result;
    }
}
class CacheExporter implements ReportExporter {
    private array $cache = [];
    public function __construct(private readonly ReportExporter $exporter) {}
    public function export(array $data): string {
        $key = md5(json_encode($data));
        return $this->cache[$key] ??= $this->exporter->export($data);
    }
}
// Compose dynamically
$exporter = new CacheExporter(new LoggerExporter(new CsvExporter()));
echo $exporter->export(['id' => 1, 'name' => 'Alice']);

Real-World Use Cases

  • Logging & Monitoring — Wrap API clients or payment gateways to log each call.
  • Caching Layers — Add transparent caching to repositories or HTTP clients.
  • Validation — Wrap services with input validators before delegating logic.
  • Rate Limiting & Retry — Add resilience logic around external integrations.
  • Transformation Pipelines — Wrap output formatters or sanitizers dynamically.

Insights

Decorators are like middleware for your business logic. They shine when your concern is cross-cutting — logging, caching, validation, analytics — anything that shouldn't pollute core behavior.

Use them when you need dynamic, runtime composition. ⚠️ Avoid "lasagna code": too many stacked decorators make debugging painful.

In Laravel, middleware and pipeline patterns are real-world decorator implementations. In Symfony, event subscribers often act as light decorators around services.

6. Repository Pattern — Abstracting Data Access

The Story

Your app starts simple — $pdo->query('SELECT * FROM users'). Months later, someone adds Redis caching, then an API gateway, then a different database. Suddenly, data logic sprawls across controllers.

The Repository pattern isolates data access behind clean interfaces, so the rest of your app deals with business entities, not SQL, ORM, or HTTP details.

Example — PHP 8.4

readonly class Product {
    public function __construct(
        public int $id,
        public string $name,
        public float $price
    ) {}
}

interface ProductRepository {
    public function find(int $id): ?Product;
    public function all(): array;
}
class MySQLProductRepository implements ProductRepository {
    public function __construct(private readonly \PDO $pdo) {}
    public function find(int $id): ?Product {
        $stmt = $this->pdo->prepare('SELECT * FROM products WHERE id = :id');
        $stmt->execute(['id' => $id]);
        $row = $stmt->fetch();
        return $row ? new Product(...$row) : null;
    }
    public function all(): array {
        return array_map(
            fn($row) => new Product(...$row),
            $this->pdo->query('SELECT * FROM products')->fetchAll()
        );
    }
}

Real-World Use Cases

  • ORM Abstraction — Switch between Doctrine, Eloquent, or raw SQL without rewriting logic.
  • API Gateways — Create repositories that pull from external APIs instead of databases.
  • CQRS Read Models — Implement separate read/write repositories for performance.
  • Search Adapters — Use different repositories for ElasticSearch, Meilisearch, or SQL.
  • Testing Isolation — Mock repositories easily for unit tests.
  • Caching Decorators — Wrap repositories with Decorator pattern for Redis or memory caching.

Insights

Repositories give your application a single language — instead of SELECT, fetch(), and API calls, you just call ->findProduct() or ->allOrders().

They bridge the domain layer and data source, keeping controllers, services, and jobs blissfully unaware of how persistence works.

Keep repository interfaces small and meaningful to the domain. ⚠️ Avoid "anemic repositories" that just mirror database tables.

In Laravel, your Eloquent models often act like mini-repositories, but true repositories can decouple your domain from Eloquent entirely — ideal for scaling or migrating data sources.

In Symfony, repositories integrate elegantly with Doctrine and service containers for dependency injection.

7. Dependency Injection (DI) — Decoupling for Freedom

The Story

Every developer hits this wall: You write a class that depends on a specific logger, mailer, and database connection. It works… until you need to test it, or switch one dependency. Suddenly you're refactoring constructors everywhere.

Dependency Injection (DI) flips that around. Instead of creating dependencies inside a class, you receive them — gaining flexibility, testability, and composability.

Example — PHP 8.4

interface PaymentGateway {
    public function charge(float $amount): void;
}

class StripeGateway implements PaymentGateway {
    public function charge(float $amount): void {
        echo "Charged \${$amount} via Stripe\n";
    }
}
readonly class OrderService {
    public function __construct(private PaymentGateway $gateway) {}
    public function checkout(float $amount): void {
        $this->gateway->charge($amount);
    }
}
// Runtime injection
$order = new OrderService(new StripeGateway());
$order->checkout(150.0);

Real-World Use Cases

  • Testing & Mocking — Swap real dependencies for fakes or mocks during tests.
  • Multi-Tenant or Multi-Region Systems — Inject strategy objects based on tenant configuration.
  • Framework Integration — Use Laravel's $this->app->bind() or Symfony's service container to wire dependencies.
  • Modular Systems — Plug new implementations (e.g., payment processors, storage adapters) without changing core code.
  • Runtime Feature Switches — Inject strategies or decorators based on environment or feature flags.

Insights

DI is the foundation pattern — the glue that makes the others shine. It enables inversion of control, where classes don't dictate their environment; they adapt to it.

Embrace constructor injection for clarity; avoid static lookups.Leverage service containers for lifecycle and scoping management. ⚠️ Too many dependencies signal a class that's doing too much — refactor instead of injecting endlessly.

In Laravel, DI is everywhere — every controller, listener, and job benefits from it. In Symfony, the service container is the heart of the framework, automatically injecting dependencies at compile time.

Bringing It All Together — A Real-World Architecture

Imagine a large-scale e-commerce system built on PHP 8.4:

  • Repository — retrieves products, orders, and customers abstractly.
  • Decorator — adds caching and logging around repositories.
  • Dependency Injection — wires services, gateways, and decorators seamlessly.

When a user places an order:

  1. OrderService (via DI) uses a PaymentGateway strategy.
  2. OrderRepository saves it through a decorated, cached layer.
  3. NotificationService (factory + observer) alerts the customer.
  4. Decorators log and time the transaction.
  5. The system remains flexible enough to swap gateways, repositories, or loggers with zero code breakage.

This isn't theory — it's how modern Laravel and Symfony apps stay clean even as they scale to hundreds of features and millions of users.

When Not to Use Patterns

Patterns are tools, not trophies. A senior engineer knows when not to use them.

❌ Don't wrap everything in abstractions "just in case." ❌ Don't use a repository for trivial queries that never change. ❌ Don't decorate a class when a single method override will do. ❌ Don't inject endlessly — sometimes a simple factory or closure suffices.

Patterns exist to clarify, not complicate. If a pattern makes your code harder to read, step back and rethink.

Thinking in Patterns

In the end, mastering design patterns isn't about memorizing diagrams — it's about building a mental map for problem-solving.

When you think in patterns, you see beyond syntax. You see systems, contracts, evolution, and clarity that endures.

PHP 8.4 gives you modern tools — readonly classes, property hooks, intersection types, enhanced typing — that make these classic patterns not just viable, but elegant.

The next time you design a new feature, don't just ask,

"How do I make this work?" Ask, "Which pattern makes this beautifully maintainable?"

Let's Share and Learn Together

Design patterns are not just about code — they're about the shared wisdom of our craft.

I'd love to hear your thoughts:

  • Which of these patterns have shaped the way you build in PHP?
  • Have you found creative combinations that solved real-world challenges?
  • What lessons have you learned applying them in large systems?

Your insights can help others write more elegant, maintainable, and resilient code.

Thank you for reading — and for sharing your perspective. 🌟

✍️ 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