Kevin Aubrée

Blog / · 6 min read

Clean Architecture with Symfony: keep Doctrine, Messenger, and the container in their place

Symfony already encourages clean services, but that is not enough. Here's how to isolate the domain, structure use cases, wire Doctrine and Messenger, and know when the cost is justified.

Clean Architecture with Symfony: keep Doctrine, Messenger, and the container in their place

Symfony gives you more discipline than many frameworks.

The container is explicit, autowiring pushes dependency injection, controllers can stay thin, Doctrine already has repositories, and Messenger gives you a message bus. It is easy to believe Clean Architecture is almost automatic.

It is not.

A Symfony project can still end up with Doctrine entities carrying too much business logic, Manager services orchestrating everything, hidden subscribers, and Messenger handlers becoming small applications. The framework is clean. Your architecture can still blur.

Clean Architecture with Symfony means deciding deliberately where business logic lives, where use cases live, and where the framework is allowed to enter.

Symfony is not your domain

A Doctrine entity often looks like a domain entity. That is where the trap starts.

Doctrine wants to hydrate, track changes, manage proxies, and map relations. Your domain wants to protect business invariants.

Those roles can coexist on small modules. Once rules become serious, mixing them gets expensive:

  • tests need a kernel or database for a simple rule;
  • setters exist for Doctrine but are dangerous for the business;
  • lazy loading happens inside loops;
  • invariants can be bypassed by hydration or fixtures;
  • application services know too much about persistence.

The first decision is per module: Doctrine entity as a lightweight business model, or separate pure domain model.

Pragmatic structure

src/
  Billing/
    Domain/
      Invoice.php
      InvoiceId.php
      InvoiceStatus.php
    Application/
      PayInvoice/
        PayInvoiceCommand.php
        PayInvoiceHandler.php
      Port/
        InvoiceRepository.php
        PaymentGateway.php
    Infrastructure/
      Doctrine/
        DoctrineInvoiceRepository.php
        InvoiceEntity.php
        InvoiceMapper.php
    UserInterface/
      Http/
        PayInvoiceController.php

You can keep src/Controller, src/Entity, and src/Repository for simple projects. For a dense bounded context, grouping by module makes dependencies easier to read.

Domain: no Doctrine, no Symfony Validator

final class Invoice
{
    private function __construct(
        private readonly InvoiceId $id,
        private InvoiceStatus $status,
        private Money $amount,
    ) {}

    public static function issue(InvoiceId $id, Money $amount): self
    {
        if ($amount->isZeroOrNegative()) {
            throw new InvalidInvoiceAmount();
        }

        return new self($id, InvoiceStatus::Issued, $amount);
    }

    public static function reconstitute(
        InvoiceId $id,
        InvoiceStatus $status,
        Money $amount,
    ): self {
        return new self($id, $status, $amount);
    }

    public function markAsPaid(): void
    {
        if ($this->status !== InvoiceStatus::Issued) {
            throw new InvoiceCannotBePaid($this->id, $this->status);
        }

        $this->status = InvoiceStatus::Paid;
    }
}

This code is tested without Symfony. The domain should not need the container to exist.

Application: use cases and ports

final readonly class PayInvoiceCommand
{
    public function __construct(
        public string $invoiceId,
        public string $paymentReference,
    ) {}
}
final readonly class PayInvoiceHandler
{
    public function __construct(
        private InvoiceRepository $invoices,
        private PaymentGateway $payments,
    ) {}

    public function __invoke(PayInvoiceCommand $command): void
    {
        $invoice = $this->invoices->get(new InvoiceId($command->invoiceId));

        $this->payments->capture($invoice, $command->paymentReference);
        $invoice->markAsPaid();

        $this->invoices->save($invoice);
    }
}

The handler knows a port, not Doctrine.

interface InvoiceRepository
{
    public function get(InvoiceId $id): Invoice;

    public function save(Invoice $invoice): void;
}

Infrastructure: Doctrine as adapter

#[ORM\Entity]
#[ORM\Table(name: 'invoices')]
class InvoiceEntity
{
    #[ORM\Id]
    #[ORM\Column(type: 'string')]
    public string $id;

    #[ORM\Column(type: 'string')]
    public string $status;

    #[ORM\Column(type: 'integer')]
    public int $amountInCents;

    #[ORM\Column(type: 'string')]
    public string $currency;
}
final readonly class DoctrineInvoiceRepository implements InvoiceRepository
{
    public function __construct(private EntityManagerInterface $entityManager) {}

    public function get(InvoiceId $id): Invoice
    {
        $entity = $this->entityManager->find(InvoiceEntity::class, $id->toString());

        if (!$entity instanceof InvoiceEntity) {
            throw new InvoiceNotFound($id);
        }

        return InvoiceMapper::toDomain($entity);
    }

    public function save(Invoice $invoice): void
    {
        $entity = $this->entityManager->find(
            InvoiceEntity::class,
            $invoice->id()->toString(),
        ) ?? new InvoiceEntity();

        InvoiceMapper::fillEntity($entity, $invoice);
        $this->entityManager->persist($entity);
        $this->entityManager->flush();
    }
}

The mapper is friction. But it makes the choice explicit: Doctrine serves persistence, the domain object serves the business.

Two details matter: InvoiceMapper::toDomain() should use Invoice::reconstitute() to rebuild an already paid invoice, and fillEntity() should update the managed entity instead of creating a fresh entity with the same primary key.

Messenger is useful, not mandatory

Messenger fits use-case driven architecture. But not every use case needs a bus.

framework:
    messenger:
        default_bus: command.bus
        buses:
            command.bus:
                middleware:
                    - doctrine_transaction
            query.bus: ~
            event.bus:
                default_middleware: allow_no_handlers

The command bus can carry Doctrine transactions. The query bus usually does not need them. The event bus handles reactions.

The trap is multiplying buses without a behavioral difference. If two buses have the same middleware stack, you may have added vocabulary instead of architecture.

Another trap: putting network calls inside a handler covered by doctrine_transaction. Capturing payment or reserving stock while a SQL transaction stays open creates long locks, and the external effect will not roll back if flush fails. For critical side effects, prefer an outbox, an after-commit message, or a separate idempotent step.

Validation has three levels

Separate:

  1. syntactic validation: HTTP payload, types, required fields;
  2. application validation: rights, resource existence, command consistency;
  3. business invariant: rule that must stay true everywhere.

Example:

  • payment_reference is required: HTTP edge;
  • invoice exists: application/repository;
  • a paid invoice cannot be paid again: domain.

If you put everything into Symfony constraints on a Doctrine entity, the rule depends on one validation context. For some modules, that is enough. For a core domain, it is fragile.

Testing strategy

public function test_paid_invoice_cannot_be_paid_again(): void
{
    $invoice = InvoiceBuilder::paid();

    $this->expectException(InvoiceCannotBePaid::class);

    $invoice->markAsPaid();
}
public function test_pay_invoice_captures_payment_and_saves_invoice(): void
{
    $invoices = new InMemoryInvoiceRepository([
        InvoiceBuilder::issued(id: 'inv_123'),
    ]);
    $payments = new FakePaymentGateway();

    $handler = new PayInvoiceHandler($invoices, $payments);

    $handler(new PayInvoiceCommand('inv_123', 'pay_456'));

    self::assertTrue($payments->captured('pay_456'));
}

Use Symfony functional tests for routing, serialization, auth, and wiring. Do not make them the only way to verify business behavior.

When to keep Symfony classic

Stay simple when:

  • the module is admin CRUD;
  • rules are stable and few;
  • Doctrine entity plus repository gives acceptable tests;
  • mapping would slow the team down;
  • the product has not proved its domain yet.

Strengthen architecture when:

  • business rules change weekly;
  • HTTP, CLI, async messages, and webhooks trigger the same use case;
  • tests are slow or fragile;
  • Doctrine entities carry too many unrelated methods;
  • side effects need control: payment, email, stock, legal documents.

Clean Architecture is not “more Symfony”. It is less framework dependency where the business must last.

Sources

Kevin Aubrée

Keep reading

Back to blog