Skip to main content
  1. Languages/
  2. PHP Guides/

Mastering Advanced PHP Design Patterns: Repository, Strategy, and Observer in 2025

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

Introduction
#

Welcome back to PHP DevPro. If you are reading this in 2025, you know that the landscape of PHP has stabilized into a robust, enterprise-grade language. Gone are the days of spaghetti scripts and include headers scattered across files. Today, we deal with strict typing, JIT compilation, and architectures that rival Java or C# in complexity and reliability.

However, frameworks like Laravel and Symfony have become so powerful that they often hide the underlying mechanics of good software design. While “Magic” is great for rapid prototyping, it can become a nightmare for long-term maintenance if you don’t understand the architectural principles beneath it.

In this deep-dive tutorial, we aren’t just going to define patterns; we are going to build a modular E-commerce Order Processing System from scratch. We will combine three of the most critical design patterns in modern development:

  1. Repository Pattern: To decouple our business logic from the database layer.
  2. Strategy Pattern: To handle complex, interchangeable logic (like shipping calculations) at runtime.
  3. Observer Pattern: To handle side effects (like email notifications) without clogging up our core processes.

By the end of this article, you will have a production-ready template that adheres to SOLID principles and is fully testable.

Why This Matters in 2025
#

The era of monolithic codebases is fading. Even within a monolith, we treat modules as isolated components. By mastering these patterns, you ensure your code is:

  • Testable: You can mock database calls easily.
  • Extensible: You can add new shipping methods without touching existing code (Open/Closed Principle).
  • Maintainable: Junior developers can understand where logic lives without tracing through 5,000 lines of a controller.

Prerequisites and Environment Setup
#

Before we write a single line of code, let’s ensure our environment is ready. We are targeting PHP 8.3 features.

Requirements
#

  • PHP 8.2 or 8.3: We will use readonly classes, Intersection Types, and Constructor Property Promotion.
  • Composer: For autoloading.
  • PDO Extension: Enabled for database interaction.

Project Structure
#

We will create a clean directory structure to simulate a professional domain-driven design approach.

mkdir php-patterns-pro
cd php-patterns-pro
composer init --name="phpdevpro/advanced-patterns" --type="project" --require="php:^8.3" -n
mkdir -src

Update your composer.json to configure PSR-4 autoloading:

{
    "name": "phpdevpro/advanced-patterns",
    "require": {
        "php": "^8.3"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    }
}

Run composer dump-autoload to finalize the setup.


Pattern 1: The Repository Pattern
#

The Repository Pattern is often misunderstood. It is not just a wrapper around your ORM (Eloquent/Doctrine). Ideally, it is an abstraction that allows your application to treat the database as an in-memory collection.

Why use it?
#

Directly calling SQL or ORM methods in your controllers or services couples your application to the database implementation. If you want to switch from MySQL to PostgreSQL, or more importantly, if you want to unit test your service without a database connection, you are stuck.

1. The Domain Entity
#

First, let’s define what an Order looks like using a PHP 8.3 readonly class.

// src/Domain/Order.php
namespace App\Domain;

readonly class Order
{
    public function __construct(
        public int $id,
        public float $amount,
        public string $currency,
        public string $status = 'pending'
    ) {}
}

2. The Repository Interface
#

This is the contract. Our service layer will only know about this interface, not the implementation.

// src/Repository/OrderRepositoryInterface.php
namespace App\Repository;

use App\Domain\Order;

interface OrderRepositoryInterface
{
    public function find(int $id): ?Order;
    public function save(Order $order): bool;
}

3. The Concrete Implementation (PDO)
#

Here is where the “dirty” work happens. We keep SQL isolated here.

// src/Repository/PDOOrderRepository.php
namespace App\Repository;

use App\Domain\Order;
use PDO;

class PDOOrderRepository implements OrderRepositoryInterface
{
    public function __construct(
        private PDO $pdo
    ) {}

    public function find(int $id): ?Order
    {
        $stmt = $this->pdo->prepare("SELECT * FROM orders WHERE id = :id");
        $stmt->execute(['id' => $id]);
        $data = $stmt->fetch(PDO::FETCH_ASSOC);

        if (!$data) {
            return null;
        }

        return new Order(
            $data['id'],
            $data['amount'],
            $data['currency'],
            $data['status']
        );
    }

    public function save(Order $order): bool
    {
        $stmt = $this->pdo->prepare("
            INSERT INTO orders (id, amount, currency, status) 
            VALUES (:id, :amount, :currency, :status)
            ON DUPLICATE KEY UPDATE status = :status
        ");
        
        return $stmt->execute([
            'id' => $order->id,
            'amount' => $order->amount,
            'currency' => $order->currency,
            'status' => $order->status
        ]);
    }
}

Comparison: Repository vs. Active Record
#

Feature Active Record (e.g., Eloquent) Repository Pattern
Simplicity High (Great for CRUD) Moderate (Requires boilerplate)
Decoupling Low (Models know about DB) High (Domain is pure)
Testability Harder (Needs DB or complex mocks) Easy (Mock the Interface)
Performance Can introduce overhead Tunable SQL per method

Pattern 2: The Strategy Pattern
#

In our scenario, calculating shipping costs is dynamic. We might use FedEx, DHL, or a generic Flat Rate. Hardcoding if ($provider === 'fedex') statements is a violation of the Open/Closed Principle.

The Strategy Pattern allows us to define a family of algorithms, encapsulate each one, and make them interchangeable.

1. The Strategy Interface
#

// src/Strategy/ShippingStrategyInterface.php
namespace App\Strategy;

use App\Domain\Order;

interface ShippingStrategyInterface
{
    public function calculateCost(Order $order): float;
}

2. Concrete Strategies
#

FedEx Strategy:

// src/Strategy/FedExShippingStrategy.php
namespace App\Strategy;

use App\Domain\Order;

class FedExShippingStrategy implements ShippingStrategyInterface
{
    public function calculateCost(Order $order): float
    {
        // Simulate API complexity
        // In real life, this might call an external HTTP API
        return $order->amount * 0.05 + 10.00; 
    }
}

Flat Rate Strategy:

// src/Strategy/FlatRateShippingStrategy.php
namespace App\Strategy;

use App\Domain\Order;

class FlatRateShippingStrategy implements ShippingStrategyInterface
{
    public function calculateCost(Order $order): float
    {
        return 15.00;
    }
}

Visualizing the Strategy Pattern
#

Here is how the Service interacts with the specific strategies. Note that the Service depends on the Interface, not the specific classes.

classDiagram class OrderService { -ShippingStrategyInterface strategy +setStrategy(ShippingStrategyInterface) +processOrder() } class ShippingStrategyInterface { <<interface>> +calculateCost(Order) } class FedExShippingStrategy { +calculateCost(Order) } class FlatRateShippingStrategy { +calculateCost(Order) } OrderService --> ShippingStrategyInterface ShippingStrategyInterface <|.. FedExShippingStrategy ShippingStrategyInterface <|.. FlatRateShippingStrategy

Pattern 3: The Observer Pattern
#

When an order is placed, we often need to:

  1. Send a confirmation email.
  2. Log the transaction to a file.
  3. Notify the warehouse via Slack.

If we put all this code in the OrderService, the class becomes massive and hard to test. The Observer Pattern (often implemented via Events in frameworks) solves this by allowing “Listeners” to subscribe to an event.

While PHP has SplSubject and SplObserver built-in, they are a bit dated. In modern PHP, we typically build a lightweight Event Dispatcher or use PSR-14. For this article, we will build a clean, typed implementation.

1. The Event and Subscriber Interfaces
#

// src/Observer/EventInterface.php
namespace App\Observer;

interface EventInterface
{
    public function getName(): string;
}

// src/Observer/ObserverInterface.php
namespace App\Observer;

interface ObserverInterface
{
    public function handle(EventInterface $event): void;
}

2. The Event Dispatcher (The Subject)
#

// src/Observer/EventDispatcher.php
namespace App\Observer;

class EventDispatcher
{
    /** @var array<string, ObserverInterface[]> */
    private array $listeners = [];

    public function subscribe(string $eventName, ObserverInterface $observer): void
    {
        $this->listeners[$eventName][] = $observer;
    }

    public function dispatch(EventInterface $event): void
    {
        $eventName = $event->getName();
        if (!isset($this->listeners[$eventName])) {
            return;
        }

        foreach ($this->listeners[$eventName] as $listener) {
            $listener->handle($event);
        }
    }
}

3. Concrete Event and Observers
#

The Event:

// src/Events/OrderPlacedEvent.php
namespace App\Events;

use App\Domain\Order;
use App\Observer\EventInterface;

readonly class OrderPlacedEvent implements EventInterface
{
    public function __construct(
        public Order $order
    ) {}

    public function getName(): string
    {
        return 'order.placed';
    }
}

The Email Listener:

// src/Listeners/EmailNotificationListener.php
namespace App\Listeners;

use App\Observer\ObserverInterface;
use App\Observer\EventInterface;
use App\Events\OrderPlacedEvent;

class EmailNotificationListener implements ObserverInterface
{
    public function handle(EventInterface $event): void
    {
        if (!$event instanceof OrderPlacedEvent) {
            return;
        }

        // Logic to send email
        echo sprintf(
            "[Email Service] Sending confirmation for Order #%d.\n", 
            $event->order->id
        );
    }
}

Putting It All Together: The Service Layer
#

Now, we bring the Repository, Strategy, and Observer patterns together into a cohesive OrderService. This is the heart of our application.

// src/Service/OrderService.php
namespace App\Service;

use App\Domain\Order;
use App\Repository\OrderRepositoryInterface;
use App\Strategy\ShippingStrategyInterface;
use App\Observer\EventDispatcher;
use App\Events\OrderPlacedEvent;

class OrderService
{
    public function __construct(
        private OrderRepositoryInterface $repository,
        private EventDispatcher $dispatcher,
        private ShippingStrategyInterface $shippingStrategy
    ) {}

    public function setShippingStrategy(ShippingStrategyInterface $strategy): void
    {
        $this->shippingStrategy = $strategy;
    }

    public function processOrder(int $orderId): void
    {
        // 1. Retrieve Data (Repository)
        $order = $this->repository->find($orderId);
        
        if (!$order) {
            throw new \Exception("Order not found.");
        }

        // 2. Business Logic (Strategy)
        $shippingCost = $this->shippingStrategy->calculateCost($order);
        
        echo sprintf(
            "Processing Order #%d. Total Amount: %.2f (Shipping: %.2f)\n", 
            $order->id, 
            $order->amount + $shippingCost,
            $shippingCost
        );

        // 3. Persist State (Repository)
        // In a real app, we might update status to 'processed' here
        $this->repository->save($order);

        // 4. Trigger Side Effects (Observer)
        $event = new OrderPlacedEvent($order);
        $this->dispatcher->dispatch($event);
    }
}

Execution Flow
#

The following sequence diagram illustrates the lifecycle of a processOrder request.

sequenceDiagram participant Client participant Service as OrderService participant Repo as OrderRepository participant Strat as ShippingStrategy participant Dispatch as EventDispatcher participant Listener as EmailListener Client->>Service: processOrder(101) Service->>Repo: find(101) Repo-->>Service: Returns Order Object Service->>Strat: calculateCost(Order) Strat-->>Service: Returns Float (Cost) Service->>Repo: save(Order) Service->>Dispatch: dispatch(OrderPlacedEvent) Dispatch->>Listener: handle(OrderPlacedEvent) Listener-->>Dispatch: void Service-->>Client: void

Running the Application
#

Create an index.php in your root folder to execute the code. Note that for this demonstration, we will use an In-Memory Repository to avoid needing a real MySQL database connection, proving the power of the Repository pattern immediately.

Mock Repository for Testing
#

// src/Repository/InMemoryOrderRepository.php
namespace App\Repository;

use App\Domain\Order;

class InMemoryOrderRepository implements OrderRepositoryInterface
{
    private array $orders = [];

    public function save(Order $order): bool
    {
        $this->orders[$order->id] = $order;
        return true;
    }

    public function find(int $id): ?Order
    {
        return $this->orders[$id] ?? null;
    }
}

The Main Script (index.php)
#

<?php

require __DIR__ . '/vendor/autoload.php';

use App\Domain\Order;
use App\Repository\InMemoryOrderRepository;
use App\Strategy\FedExShippingStrategy;
use App\Strategy\FlatRateShippingStrategy;
use App\Observer\EventDispatcher;
use App\Listeners\EmailNotificationListener;
use App\Service\OrderService;

// 1. Setup Dependencies
$repository = new InMemoryOrderRepository();
$dispatcher = new EventDispatcher();

// 2. Seed Data
$repository->save(new Order(101, 100.00, 'USD'));

// 3. Register Listeners
$dispatcher->subscribe('order.placed', new EmailNotificationListener());

// 4. Initialize Service with Default Strategy (FedEx)
$service = new OrderService(
    $repository,
    $dispatcher,
    new FedExShippingStrategy()
);

echo "--- Run 1: FedEx ---\n";
$service->processOrder(101);

echo "\n--- Run 2: Switching to Flat Rate ---\n";
// Runtime Strategy Switching!
$service->setShippingStrategy(new FlatRateShippingStrategy());
$service->processOrder(101);

Expected Output
#

--- Run 1: FedEx ---
Processing Order #101. Total Amount: 115.00 (Shipping: 15.00)
[Email Service] Sending confirmation for Order #101.

--- Run 2: Switching to Flat Rate ---
Processing Order #101. Total Amount: 115.00 (Shipping: 15.00)
[Email Service] Sending confirmation for Order #101.

Performance Analysis and Pitfalls
#

While these patterns add incredible flexibility, they come with considerations you must be aware of in a high-traffic production environment.

1. The N+1 Problem in Repositories
#

The most common pitfall with Repositories (especially when wrapping ORMs) is the “N+1 query problem.”

  • Problem: You fetch a list of orders, and then inside a loop, you call $order->getItems() which triggers a SQL query for every single order.
  • Solution: Ensure your Repository has methods like findWithItems(int $id) or findAllWithRelations(). Eager loading is crucial.

2. Object Instantiation Overhead
#

In the Strategy pattern, if you have 50 different shipping strategies, instantiating all of them just to pick one is wasteful.

  • Optimization: Use a Dependency Injection Container (like PHP-DI) with lazy loading, or a StrategyFactory that only instantiates the required class based on context.

3. Observer Memory Leaks
#

In long-running PHP processes (like Swoole, RoadRunner, or worker scripts), Observers can cause memory leaks if they hold onto heavy resources and are never deregistered.

  • Best Practice: Keep Listeners stateless. If they need state, pass it via the Event object.

Conclusion
#

By combining Repository, Strategy, and Observer patterns, we have transformed a potential mess of procedural code into a system that is:

  1. Agnostic to Storage: We swapped PDO for In-Memory without changing the Service.
  2. Dynamic: We changed shipping logic at runtime.
  3. Extensible: We added email notifications without modifying the processOrder logic.

In 2025, being a senior PHP developer means understanding how to organize code, not just how to write syntax. These patterns are the building blocks of clean architecture frameworks like Symfony and Laravel, but knowing how to implement them in vanilla PHP gives you the power to debug, optimize, and architect better systems.

Further Reading
#

  • PHP 8.3 Documentation: Look up “Readonly classes” and “Disjunctive Normal Form Types”.
  • Refactoring to Patterns: A classic book by Joshua Kerievsky.
  • The Twelve-Factor App: Methodology for building software-as-a-service apps.

Ready to refactor your legacy code? Start small. Implement a Repository for your most complex model today, and watch your testability soar.


If you found this guide helpful, share it with your team or subscribe to the PHP DevPro feed for more deep dives into modern PHP architecture.