Design Patterns in PHP: An Overview Independent of Magento/Symfony
AI generated
<?php
8.4
PHP 8.4 · Design Patterns · OOP
Design Patterns in PHP
an overview independent of Magento and Symfony

Design patterns are often explained tied to a specific framework, yet they are named, reusable solution shapes for design problems that show up in any object-oriented PHP codebase, regardless of whether a framework is even involved. This article presents the most important classic design patterns, Factory Method, Singleton, Decorator, Strategy, Observer and Repository, using nothing but plain PHP 8.4, organized by the three classic GoF categories Creational, Structural and Behavioral. No Magento, no Symfony, no ORM tie-in, just the mechanics of the patterns themselves and an honest assessment of when the extra code actually pays off.

14 min read Factory · Strategy · Observer · Decorator · Repository PHP 8.4 · framework-independent

1. What a design pattern actually is

A design pattern is a named, reusable solution shape for a recurring design problem, not a library, not a framework feature, and not a ready-made piece of code to copy. It describes a structure of classes and their relationships to each other that solves a specific design problem, without being tied to a concrete implementation, a specific programming language, or even a specific framework. When two developers talk about a "factory" or a "strategy," they are communicating about a structure, not a specific line of code.

The design patterns movement was shaped by the book from the so-called "Gang of Four," which divides patterns into three categories: Creational patterns deal with object creation, Structural patterns with composing classes and objects into larger structures, Behavioral patterns with distributing responsibility and communication between objects. These three categories serve as a map for the rest of this article: every pattern presented is assigned to exactly one of these categories, so it becomes clear which underlying problem it actually solves.

An important distinction from the start: a design pattern is independent of Magento, Symfony, or any other framework. Framework-specific implementations, for instance how Magento structures its own ObjectManager or its own factory classes, are merely a concrete application of the general principle to a specific codebase. This article deliberately stays at the level of the general principle, with plain PHP code that works in any project regardless of the framework in use.

2. Creational patterns part 1: Factory Method

The problem Factory Method solves arises almost always in the same way: a new ConcreteClass() call is scattered across a codebase, and every one of these locations must be changed as soon as a decision changes about which concrete class should actually be instantiated. Instead of repeating this decision at every call site, a factory method encapsulates the decision logic in exactly one place and returns an instance typed against a shared interface.

The caller only knows the interface, not the concrete class. This fully decouples the code that uses an object from the code that decides which concrete implementation that object actually is.


<?php

declare(strict_types=1);

namespace Notifications;

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

final class EmailNotifier implements NotifierInterface
{
    public function send(string $message): void
    {
        // ... send an email
    }
}

final class SmsNotifier implements NotifierInterface
{
    public function send(string $message): void
    {
        // ... send an SMS
    }
}

// Factory Method: one place decides which concrete class is returned
final class NotifierFactory
{
    public static function create(string $channel): NotifierInterface
    {
        return match ($channel) {
            'email' => new EmailNotifier(),
            'sms' => new SmsNotifier(),
            default => throw new \InvalidArgumentException("Unknown channel: $channel"),
        };
    }
}

$notifier = NotifierFactory::create('email');
$notifier->send('Order confirmed');

New channels can be added without touching the calling code anywhere, as long as only the factory method itself is extended with a new match branch. That is the actual benefit of Factory Method: changes to creation logic stay local instead of spreading across the entire codebase.

3. Creational patterns part 2: Singleton and its limits

Singleton promises that exactly one instance of a class exists during the entire runtime of a program, accessible through a global static access point. In practice, however, this pattern has become one of the most frequently criticized design patterns of all, because its actual cost usually exceeds its promised benefit.

The cost lies in global, mutable state: every class that internally accesses a singleton has a hidden dependency that is not visible from its public signature. This considerably complicates testing, because a singleton must be reset between test cases to avoid side effects, and it makes the actual dependency graph of an application harder to follow. The modern alternative is dependency injection: an instance is created once, centrally, and explicitly passed through the constructor to every place that needs it, instead of fetching itself from a global access point. The instance can well remain unique, only the way code gets to it changes from implicit-global to explicit-injected, which considerably improves testability and traceability.

4. Structural patterns: Decorator

Decorator solves the problem of extending an object with additional behavior without changing its class and without writing a separate subclass for every conceivable combination of additional behavior. A decorator implements the same interface as the object it wraps, holds an internal reference to that object, and calls its method, adding its own behavior before or after.

The decisive advantage over inheritance: multiple decorators can be nested arbitrarily, each one adding exactly one additional capability, without a combinatorial explosion of subclasses needing to exist for every possible combination.


<?php

declare(strict_types=1);

namespace Notifications;

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

final class BaseNotifier implements NotifierInterface
{
    public function send(string $message): void
    {
        echo "Sending: {$message}\n";
    }
}

// Decorator: wraps any NotifierInterface, adds behavior without modifying it
final class LoggingNotifierDecorator implements NotifierInterface
{
    public function __construct(
        private readonly NotifierInterface $inner,
    ) {
    }

    public function send(string $message): void
    {
        error_log(sprintf('Sending notification: %s', $message));
        $this->inner->send($message);
    }
}

final class RateLimitedNotifierDecorator implements NotifierInterface
{
    private int $sentCount = 0;

    public function __construct(
        private readonly NotifierInterface $inner,
        private readonly int $maxPerRequest = 5,
    ) {
    }

    public function send(string $message): void
    {
        if ($this->sentCount >= $this->maxPerRequest) {
            throw new \RuntimeException('Rate limit exceeded for this request');
        }

        $this->sentCount++;
        $this->inner->send($message);
    }
}

// Stacking decorators: each one adds exactly one capability
$notifier = new RateLimitedNotifierDecorator(
    new LoggingNotifierDecorator(new BaseNotifier())
);
$notifier->send('Order confirmed');

The calling code still only knows NotifierInterface and does not need to know anything about the concrete nesting. Every decorator can be tested independently, and new additional capabilities arrive as a new decorator without changing existing code.

5. Behavioral patterns part 1: Strategy

Strategy encapsulates interchangeable algorithms behind a shared interface and selects at runtime which concrete algorithm is actually used. Instead of maintaining a large if/match cascade inside a class that contains a separate branch for every variant, each variant is implemented as its own class and handed to the context class from outside.

New variants can be added without changing the context class itself, which corresponds to the open/closed principle: open for extension, closed for modification.


<?php

declare(strict_types=1);

namespace Pricing;

interface PricingStrategyInterface
{
    public function calculate(int $baseAmountInCents): int;
}

final class RegularPricing implements PricingStrategyInterface
{
    public function calculate(int $baseAmountInCents): int
    {
        return $baseAmountInCents;
    }
}

final class PercentageDiscountPricing implements PricingStrategyInterface
{
    public function __construct(private readonly int $percentOff)
    {
    }

    public function calculate(int $baseAmountInCents): int
    {
        return (int) round($baseAmountInCents * (100 - $this->percentOff) / 100);
    }
}

// Context: holds a strategy, delegates the actual calculation
final class PriceCalculator
{
    public function __construct(
        private readonly PricingStrategyInterface $strategy,
    ) {
    }

    public function priceFor(int $baseAmountInCents): int
    {
        return $this->strategy->calculate($baseAmountInCents);
    }
}

$calculator = new PriceCalculator(new PercentageDiscountPricing(20));
echo $calculator->priceFor(10000); // 8000

6. Behavioral patterns part 2: Observer

Observer decouples an event source from the parts of the code that need to react to an event, without the source needing to know anything about the concrete observers. A subject holds a list of observers and, on an event, notifies all registered observers through a shared interface, without knowing their concrete class.

This pattern can be fully implemented with a simple, self-defined interface, entirely without the built-in SPL classes SplSubject and SplObserver, which in practice are rarely flexible enough for real requirements.


<?php

declare(strict_types=1);

namespace Orders;

interface OrderObserverInterface
{
    public function onOrderPlaced(string $orderId): void;
}

final class OrderPlacementLogger implements OrderObserverInterface
{
    public function onOrderPlaced(string $orderId): void
    {
        error_log("Order placed: {$orderId}");
    }
}

final class OrderConfirmationMailer implements OrderObserverInterface
{
    public function onOrderPlaced(string $orderId): void
    {
        // ... send confirmation email
    }
}

// Subject: holds observers, notifies them without knowing their concrete class
final class OrderSubject
{
    /** @var OrderObserverInterface[] */
    private array $observers = [];

    public function subscribe(OrderObserverInterface $observer): void
    {
        $this->observers[] = $observer;
    }

    public function placeOrder(string $orderId): void
    {
        // ... actual order placement logic

        foreach ($this->observers as $observer) {
            $observer->onOrderPlaced($orderId);
        }
    }
}

$subject = new OrderSubject();
$subject->subscribe(new OrderPlacementLogger());
$subject->subscribe(new OrderConfirmationMailer());
$subject->placeOrder('ORD-1001');

7. Repository as an architectural boundary

Repository differs from the previous patterns in that it describes less a single class relationship and more an architectural boundary. Data access is abstracted behind an interface, so the actual business logic does not depend on whether data comes from a database, a file, or an in-memory store. Business code programs exclusively against the interface and knows nothing about the concrete storage mechanism behind it.

The following example deliberately shows an in-memory implementation, explicitly with no tie-in to any ORM or framework, to make the pure structure of the pattern visible.


<?php

declare(strict_types=1);

namespace Users;

final class User
{
    public function __construct(
        public readonly int $id,
        public readonly string $email,
    ) {
    }
}

interface UserRepositoryInterface
{
    public function find(int $id): ?User;
    public function save(User $user): void;
}

// Concrete implementation, plain PHP, no ORM, no framework
final class InMemoryUserRepository implements UserRepositoryInterface
{
    /** @var array<int, User> */
    private array $users = [];

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

    public function save(User $user): void
    {
        $this->users[$user->id] = $user;
    }
}

// Business logic depends only on the interface
final class RegisterUser
{
    public function __construct(
        private readonly UserRepositoryInterface $repository,
    ) {
    }

    public function handle(int $id, string $email): void
    {
        $this->repository->save(new User($id, $email));
    }
}

A different storage mechanism, say a real database connection, can later be added as another implementation of UserRepositoryInterface, without RegisterUser needing to change a single line. This exact swappability is what makes Repository one of the most frequently used design patterns in growing codebases.

8. Combining patterns without overengineering

Design patterns solve problems that must actually exist first, before the extra code a pattern brings along pays off. Anyone building a factory for a single concrete class that will never get a second implementation adds indirection without a real benefit standing against it. The same applies to Strategy with exactly one strategy, or Observer with exactly one observer that will never get a second one.

A useful warning sign for overengineering: if an abstraction exists but has never gained a second concrete implementation since its introduction, and none is concretely planned, the abstraction has probably added more complexity than it saved. A simple, direct function call or a single class without an interface is often the clearer solution in this case, and a design pattern can always still be introduced later, once the actual need for swappability arises.

9. Design patterns in direct comparison

The following table summarizes the design patterns covered in this article along category, the problem solved, and a typical warning sign of overuse.

Pattern Category Problem solved Overengineering warning sign
Factory Method Creational Centralizing creation logic instead of scattering it Only one concrete class will ever exist
Singleton Creational Making exactly one instance globally available Almost always, due to hidden global dependencies
Decorator Structural Adding behavior without an inheritance explosion Only a single additional capability is ever needed
Strategy Behavioral Choosing interchangeable algorithms at runtime Only one variant exists and will stay that way
Repository Structural / architectural Decoupling data access from business logic Never more than one storage implementation planned

The table makes visible that each of these design patterns solves a specific problem, and that each also has a specific warning sign by which overengineering can be recognized. Anyone applying a pattern without the associated problem actually being present in their own codebase trades simplicity for indirection without receiving anything in return.

10. Summary

Design patterns are named solution shapes for recurring design problems, independent of Magento, Symfony, or any other framework. Factory Method centralizes creation logic, Decorator adds behavior without an inheritance explosion, Strategy makes algorithms swappable at runtime, Observer decouples event sources from their reactions, Repository separates business logic from the concrete storage implementation. Singleton, on the other hand, belongs in most modern codebases among the patterns one deliberately avoids, because its actual cost, hidden global dependencies and complicated tests, usually exceeds the benefit.

The most important guiding principle remains: a pattern only pays off once the problem it solves actually exists in your own codebase. Where there is no second implementation, no swappable strategy, and no real need for decoupling, a design pattern only adds extra indirection without delivering anything in return.

Design Patterns in PHP, the Essentials at a Glance

Three GoF categories

Creational (creation), Structural (composition), Behavioral (responsibility and communication).

Framework-independent

Patterns are general principles. Magento or Symfony implementations are just concrete applications of them.

Singleton with caution

Global state and hidden dependencies. Dependency injection is usually the more robust alternative.

Overengineering warning sign

An abstraction with no second implementation and no concrete need costs more than it saves.

11. FAQ: Design Patterns in PHP

1What is a design pattern in PHP?
A named, reusable solution shape for a recurring design problem. Not a library, not a framework feature, but a general class structure.
2Three categories of design patterns?
Creational (creation), Structural (composition), Behavioral (responsibility and communication between objects).
3Why is Singleton an anti-pattern?
Global mutable state and hidden dependencies complicate testing. Dependency injection is usually the more robust alternative.
4Factory Method vs. Strategy?
Factory Method decides which class is instantiated. Strategy encapsulates interchangeable algorithms for an already existing instance.
5How does Decorator work without inheritance?
Implements the same interface, holds a reference to the wrapped object, and adds behavior. Multiple decorators nest arbitrarily.
6Need SplObserver for Observer?
No. A simple custom interface usually suffices and is more flexible than the built-in SPL classes.
7Is Repository tied to an ORM?
No. An interface abstracts data access, the concrete implementation can be anything, without business code needing to know.
8When does a pattern not pay off?
When the associated problem does not exist at all. Then the pattern only adds indirection without delivering benefit.
9Tied to Magento or Symfony?
No. Patterns are general OOP principles. Framework implementations are just concrete applications of them.
10How to recognize overengineering?
An abstraction with no second implementation and no concrete need has probably added more complexity than it saved.

Mironsoft

PHP development, architecture review and design pattern consulting

Want design patterns applied deliberately in your PHP project?

We help teams apply Factory, Strategy, Decorator, Observer and Repository where they actually deliver value, and deliberately avoid overengineering.

Architecture review

Checking existing code for overengineering and missing decoupling

Refactoring

Replacing global state and singletons with dependency injection

Design workshop

Working out the right design patterns directly on your own domain model