◀ Back to blog
PHP / Symfony

Design patterns in PHP

Published on 05 Sep 2024· 10 min read
#PHP#Design Patterns#Architecture

Essential patterns for modern PHP

Design patterns are proven solutions to recurring software design problems. Here are the most useful ones in PHP, illustrated with PHP 8 and Symfony code as it is written today.

A pattern is neither a library you install nor a rule to apply everywhere: it is a shared vocabulary and a solution shape that has stood the test of time. Its main benefit is to make code predictable. When a developer reads PricingStrategy or ProductRepositoryInterface, they immediately know what role the class plays and where to look for the logic. The patterns covered here are the ones you actually find in a production Symfony application: some are provided by the framework itself, others take only a few lines thanks to modern language features (constructor property promotion, attributes, strict types).

Repository Pattern

The Repository isolates data access logic behind a business-oriented interface. The rest of the application asks for "the products in this category" without knowing whether they come from MySQL through Doctrine, from an external API or from an in-memory array during tests.

interface ProductRepositoryInterface
{
    public function findById(int $id): ?Product;
    public function findByCategory(string $category): array;
    public function save(Product $product): void;
}

class DoctrineProductRepository implements ProductRepositoryInterface
{
    public function __construct(
        private EntityManagerInterface $em,
    ) {}

    public function findById(int $id): ?Product
    {
        return $this->em->find(Product::class, $id);
    }

    public function findByCategory(string $category): array
    {
        return $this->em->getRepository(Product::class)
            ->findBy(['category' => $category], ['name' => 'ASC']);
    }

    public function save(Product $product): void
    {
        $this->em->persist($product);
        $this->em->flush();
    }
}

A few important points about this implementation:

  • Services depend on ProductRepositoryInterface, never on the Doctrine class. In Symfony, autowiring automatically resolves the interface to its single implementation.
  • Methods carry business names (findByCategory) instead of exposing the QueryBuilder to the outside world: the query stays inside the repository.
  • The ?Product return type forces the caller to handle the "not found" case explicitly.

The most tangible benefit shows up in unit tests: an in-memory implementation replaces the database without any complex mocking.

final class InMemoryProductRepository implements ProductRepositoryInterface
{
    /** @var array<int, Product> */
    private array $products = [];

    public function findById(int $id): ?Product
    {
        return $this->products[$id] ?? null;
    }

    public function findByCategory(string $category): array
    {
        return array_values(array_filter(
            $this->products,
            fn (Product $p): bool => $p->getCategory() === $category,
        ));
    }

    public function save(Product $product): void
    {
        $this->products[$product->getId()] = $product;
    }
}

Classic pitfall: calling flush() in every save() is simple, but costly when you store hundreds of objects in a loop. For batch processing, provide a dedicated method or let the application layer (a command handler, for example) decide when to flush().

Strategy Pattern

The Strategy encapsulates a family of interchangeable algorithms behind a single interface. It is the antidote to long if/switch cascades that grow with every new business case: adding a pricing rule means adding a class, without touching existing code (the open/closed principle).

interface PricingStrategy
{
    public function calculate(float $basePrice): float;
}

class RegularPricing implements PricingStrategy
{
    public function calculate(float $basePrice): float
    {
        return $basePrice;
    }
}

class PremiumPricing implements PricingStrategy
{
    public function calculate(float $basePrice): float
    {
        return $basePrice * 0.8; // 20% discount
    }
}

You still have to pick the right strategy at runtime. With Symfony, the cleanest approach is to tag every implementation automatically and inject them as an iterable into a resolver. Each strategy states whether it applies:

use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;

#[AutoconfigureTag('app.pricing_strategy')]
interface CustomerPricingStrategy extends PricingStrategy
{
    public function supports(Customer $customer): bool;
}

final class PricingResolver
{
    /** @param iterable<CustomerPricingStrategy> $strategies */
    public function __construct(
        #[AutowireIterator('app.pricing_strategy')]
        private iterable $strategies,
    ) {}

    public function priceFor(Customer $customer, float $basePrice): float
    {
        foreach ($this->strategies as $strategy) {
            if ($strategy->supports($customer)) {
                return $strategy->calculate($basePrice);
            }
        }

        throw new \LogicException('No applicable pricing strategy.');
    }
}

The #[AutowireIterator] attribute has been available since Symfony 6.4; on earlier versions, #[TaggedIterator] plays the same role. A word of advice: the examples use float to stay readable, but for real amounts prefer integers in cents or a library such as brick/money to avoid rounding errors.

Observer Pattern with Symfony Events

The Observer lets an object notify other objects it does not know about. In Symfony, the EventDispatcher is a complete implementation of it: the code that creates an order publishes an event, and each listener reacts on its own (confirmation email, stock update, statistics). Adding a reaction requires no change to the emitting code.

#[AsEventListener(event: OrderCreatedEvent::class)]
class SendOrderConfirmation
{
    public function __construct(
        private MailerInterface $mailer,
    ) {}

    public function __invoke(OrderCreatedEvent $event): void
    {
        $this->mailer->send(
            new OrderConfirmationEmail($event->getOrder())
        );
    }
}

On the emitting side, you only need to dispatch the event once the order has been saved:

final class OrderService
{
    public function __construct(
        private EventDispatcherInterface $dispatcher,
    ) {}

    public function place(Order $order): void
    {
        // ... persist the order
        $this->dispatcher->dispatch(new OrderCreatedEvent($order));
    }
}

Be careful: EventDispatcher listeners run synchronously, within the same HTTP request. If sending the email fails or takes two seconds, the user is the one waiting. For slow or failure-prone work, publish a message with Symfony Messenger instead and handle it in an asynchronous worker. Another pitfall: a listener must not depend on the execution order of the others; if it has to, use the attribute's priority parameter, but this is often a sign of hidden coupling.

Builder Pattern

The Builder constructs a complex object step by step through a fluent interface. It avoids constructors with ten optional parameters and makes the calling code read like a sentence. Doctrine (QueryBuilder), Symfony Mailer (Email) and the Form component (FormBuilder) use it extensively.

class QueryBuilder
{
    private array $conditions = [];
    private ?int $limit = null;

    public function where(string $field, mixed $value): self
    {
        $this->conditions[$field] = $value;
        return $this;
    }

    public function limit(int $limit): self
    {
        $this->limit = $limit;
        return $this;
    }

    public function build(): Query
    {
        return new Query($this->conditions, $this->limit);
    }
}

Usage:

$query = (new QueryBuilder())
    ->where('status', 'published')
    ->where('category', 'php')
    ->limit(10)
    ->build();

The build() method is the right place to check that the whole is consistent (required fields, forbidden combinations) and to throw an exception before producing an invalid object. Ideally, the resulting object (Query) is immutable: the Builder is mutable, the result is not.

Bonus: Decorator with Symfony

The Decorator adds behavior to a service without modifying it, by wrapping it in a class that implements the same interface. It is ideal for caching, logging or metrics. Symfony supports it natively with the #[AsDecorator] attribute:

use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

#[AsDecorator(decorates: ProductRepositoryInterface::class)]
final class CachedProductRepository implements ProductRepositoryInterface
{
    public function __construct(
        #[AutowireDecorated]
        private ProductRepositoryInterface $inner,
        private CacheInterface $cache,
    ) {}

    public function findById(int $id): ?Product
    {
        return $this->inner->findById($id);
    }

    public function findByCategory(string $category): array
    {
        return $this->cache->get(
            'products_category_'.md5($category),
            function (ItemInterface $item) use ($category): array {
                $item->expiresAfter(300);

                return $this->inner->findByCategory($category);
            },
        );
    }

    public function save(Product $product): void
    {
        $this->inner->save($product);
    }
}

Every service that depends on ProductRepositoryInterface now receives the cached version, without a single line of its code changing. Be careful about caching Doctrine entities, though: once deserialized, they are no longer managed by the EntityManager. For caching, prefer identifiers or read-only DTOs.

Summary

  • Repository: abstraction of the persistence layer
  • Strategy: interchangeable algorithms
  • Observer: decoupling through events
  • Builder: construction of complex objects
  • Decorator: adding behavior (cache, logs) without modifying the original service

When not to use a pattern

The main risk with design patterns is not ignoring them, but applying them everywhere. An interface with a single implementation that will never get a second one, a Strategy for two cases that will never change, a Factory that merely calls new: all of this adds files and indirection without adding value. A few guidelines:

  • Start with the simplest code; introduce a pattern when a second concrete case appears, not "just in case".
  • Avoid the Singleton: in a Symfony application, the service container already shares a single instance, and global state makes tests fragile.
  • Use the patterns the framework provides (events, decoration, tagged services) rather than reimplementing them.
  • Name classes after their business role; the pattern name may appear, but it must not replace the meaning.

Used well, these few patterns are enough to structure the vast majority of PHP applications: persistence behind repositories, variable rules in strategies, side effects in listeners, and cross-cutting concerns in decorators.