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.
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:
- syntactic validation: HTTP payload, types, required fields;
- application validation: rights, resource existence, command consistency;
- business invariant: rule that must stay true everywhere.
Example:
payment_referenceis 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
Keep reading