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.
Table of Contents
- 1. What a design pattern actually is
- 2. Creational patterns part 1: Factory Method
- 3. Creational patterns part 2: Singleton and its limits
- 4. Structural patterns: Decorator
- 5. Behavioral patterns part 1: Strategy
- 6. Behavioral patterns part 2: Observer
- 7. Repository as an architectural boundary
- 8. Combining patterns without overengineering
- 9. Design patterns in direct comparison
- 10. Summary
- 11. FAQ
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?
2Three categories of design patterns?
3Why is Singleton an anti-pattern?
4Factory Method vs. Strategy?
5How does Decorator work without inheritance?
6Need SplObserver for Observer?
7Is Repository tied to an ORM?
8When does a pattern not pay off?
9Tied to Magento or Symfony?
10How to recognize overengineering?
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