- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Event-Driven Architecture Pocket Guide
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You added Symfony Messenger because someone said "we need async." Six months later, every domain object in the codebase extends some Symfony\Component\Messenger\... base class. Your OrderPlaced event is also a MessageInterface by accident. Your handlers implements MessageHandlerInterface. A junior engineer renames a namespace in the vendor directory and 40 files break. The framework that was supposed to live at the edge ended up at the center.
This is the standard Messenger failure mode, and it is avoidable. Messenger is a fine transport, and a bad domain. The fix is to treat it the same way you treat Doctrine or Guzzle: a concrete adapter behind an interface your domain owns. Your domain publishes events through a port called something like EventBus. One adapter implementation dispatches those events into a Messenger bus, which routes them to AMQP, Redis, or sync. The domain does not notice if you swap Messenger for RabbitMQ-direct, Kafka, or NATS.
This post walks the wiring end to end on PHP 8.3 and Symfony 7.
The port the domain owns
The domain has one rule: it never names a framework. The event is a plain PHP object. The bus is an interface declared inside the domain layer.
<?php
declare(strict_types=1);
namespace App\Domain\Event;
interface DomainEvent
{
public function eventId(): string;
public function occurredAt(): \DateTimeImmutable;
public function aggregateId(): string;
}
<?php
declare(strict_types=1);
namespace App\Domain\Event;
interface EventBus
{
/**
* @param iterable<DomainEvent> $events
*/
public function publish(iterable $events): void;
}
That is the entire contract. publish() takes one or more domain events and promises they will reach interested parties. It does not promise synchronous delivery, ordering across aggregates, or exactly-once semantics. Those are properties of the adapter, not the port.
A concrete event:
<?php
declare(strict_types=1);
namespace App\Domain\Order\Event;
use App\Domain\Event\DomainEvent;
final readonly class OrderPlaced implements DomainEvent
{
public function __construct(
public string $orderId,
public string $customerId,
public int $totalCents,
public string $currency,
private \DateTimeImmutable $occurredAt,
private string $eventId,
) {}
public function eventId(): string { return $this->eventId; }
public function occurredAt(): \DateTimeImmutable { return $this->occurredAt; }
public function aggregateId(): string { return $this->orderId; }
}
Notice what is missing. No Messenger import. No Symfony annotation. No serializer hint. Nothing in this file would change if you replaced Messenger with a different transport tomorrow.
The aggregate raises events into an internal collection and exposes them via a method the application layer pulls from. This is the standard "recorded events" pattern:
<?php
declare(strict_types=1);
namespace App\Domain\Order;
use App\Domain\Event\DomainEvent;
use App\Domain\Order\Event\OrderPlaced;
final class Order
{
/** @var list<DomainEvent> */
private array $recordedEvents = [];
private function __construct(
public readonly string $id,
public readonly string $customerId,
public readonly int $totalCents,
public readonly string $currency,
) {}
public static function place(
string $id,
string $customerId,
int $totalCents,
string $currency,
\DateTimeImmutable $now,
): self {
$order = new self($id, $customerId, $totalCents, $currency);
$order->recordedEvents[] = new OrderPlaced(
orderId: $id,
customerId: $customerId,
totalCents: $totalCents,
currency: $currency,
occurredAt: $now,
eventId: bin2hex(random_bytes(16)),
);
return $order;
}
/** @return list<DomainEvent> */
public function pullEvents(): array
{
$events = $this->recordedEvents;
$this->recordedEvents = [];
return $events;
}
}
The application service that places an order calls EventBus::publish() after persistence succeeds. The application service does not know how delivery happens. It does not know whether the bus is in-process, RabbitMQ, or a no-op test double.
<?php
declare(strict_types=1);
namespace App\Application\Order;
use App\Domain\Event\EventBus;
use App\Domain\Order\Order;
use App\Domain\Order\OrderRepository;
final readonly class PlaceOrder
{
public function __construct(
private OrderRepository $orders,
private EventBus $events,
private \DateTimeImmutable $clock,
) {}
public function __invoke(PlaceOrderCommand $cmd): string
{
$order = Order::place(
$cmd->orderId,
$cmd->customerId,
$cmd->totalCents,
$cmd->currency,
$this->clock,
);
$this->orders->save($order);
$this->events->publish($order->pullEvents());
return $order->id;
}
}
Three calls. Build the aggregate, persist it, publish what it recorded. The application layer has no idea Messenger exists.
The Messenger adapter
The framework finally shows up at the edge, where it belongs. The adapter wraps a Symfony Messenger bus and is the only file in the project that imports from Symfony\Component\Messenger.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Event;
use App\Domain\Event\DomainEvent;
use App\Domain\Event\EventBus;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\TransportNamesStamp;
final readonly class MessengerEventBus implements EventBus
{
public function __construct(
private MessageBusInterface $bus,
private string $transport = 'async_events',
) {}
public function publish(iterable $events): void
{
foreach ($events as $event) {
$envelope = new Envelope(
new DomainEventMessage($event),
[new TransportNamesStamp([$this->transport])],
);
$this->bus->dispatch($envelope);
}
}
}
DomainEventMessage is the only Messenger-shaped object in play. It exists so Messenger has something to route. The domain event sits inside it as a payload.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Event;
use App\Domain\Event\DomainEvent;
final readonly class DomainEventMessage
{
public function __construct(public DomainEvent $event) {}
}
That is the boundary. The domain keeps a clean OrderPlaced; Messenger gets a wrapper class that carries it.
Routing lives in config/packages/messenger.yaml. The async transport uses AMQP; the failure transport is a separate queue Messenger drains on retry.
framework:
messenger:
failure_transport: failed
transports:
async_events:
dsn: '%env(MESSENGER_AMQP_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000
multiplier: 2
max_delay: 0
options:
exchange:
name: domain_events
type: topic
queues:
order_events:
binding_keys: [order.*]
failed:
dsn: 'doctrine://default?queue_name=failed'
routing:
App\Infrastructure\Event\DomainEventMessage: async_events
MESSENGER_AMQP_DSN looks like amqp://user:pass@rabbitmq:5672/%2f/messages. The exchange is a topic exchange so consumers can subscribe to subsets of events by binding key. A worker drains the queue:
php bin/console messenger:consume async_events --time-limit=3600 --memory-limit=128M -vv
The --time-limit and --memory-limit flags exist because PHP processes leak memory across requests when the kernel stays booted for hours. The worker restarts on a timer; supervisord or systemd brings it back. Standard Symfony deployment shape.
Consumers, the same way
Handlers also live in Infrastructure. They subscribe to DomainEventMessage and dispatch into the domain by event class.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Event\Handler;
use App\Domain\Order\Event\OrderPlaced;
use App\Infrastructure\Event\DomainEventMessage;
use App\Infrastructure\Notification\CustomerNotifier;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final readonly class SendOrderConfirmationOnOrderPlaced
{
public function __construct(private CustomerNotifier $notifier) {}
public function __invoke(DomainEventMessage $message): void
{
$event = $message->event;
if (!$event instanceof OrderPlaced) {
return;
}
$this->notifier->confirmOrder(
customerId: $event->customerId,
orderId: $event->orderId,
);
}
}
One handler per side effect. Each handler is responsible for one consumer concern: send the email, write the projection row, ping the analytics pipeline. The handler is an inbound adapter. It converts a transport message back into a domain-flavored call against another port like CustomerNotifier, which itself wraps an HTTP client or SMTP.
If you want type-narrow dispatch instead of the instanceof check, register one handler per event class with a small router that matches on the wrapped event's class and delegates. The routing lives in Infrastructure, not in the domain.
What this buys you
Tests stay infrastructure-free. The PlaceOrder use case in tests/Application/ uses an in-memory EventBus:
<?php
declare(strict_types=1);
namespace App\Tests\Support;
use App\Domain\Event\DomainEvent;
use App\Domain\Event\EventBus;
final class InMemoryEventBus implements EventBus
{
/** @var list<DomainEvent> */
public array $published = [];
public function publish(iterable $events): void
{
foreach ($events as $event) {
$this->published[] = $event;
}
}
}
No MessengerBus. No kernel.boot(). No AMQP_DSN. Six lines. A unit test for PlaceOrder runs in milliseconds:
public function test_publishes_order_placed(): void
{
$orders = new InMemoryOrderRepository();
$events = new InMemoryEventBus();
$now = new \DateTimeImmutable('2026-05-18T10:00:00Z');
$handler = new PlaceOrder($orders, $events, $now);
$orderId = $handler(new PlaceOrderCommand(
orderId: 'ord-1',
customerId: 'cust-1',
totalCents: 4999,
currency: 'EUR',
));
self::assertCount(1, $events->published);
self::assertInstanceOf(OrderPlaced::class, $events->published[0]);
self::assertSame($orderId, $events->published[0]->aggregateId());
}
The transport is replaceable. Move from AMQP to Redis Streams by changing one DSN. Going further, a hand-rolled Kafka producer drops in as a KafkaEventBus that implements the same EventBus interface. The domain and the application layer do not change.
Reasoning about delivery semantics lives in one file. Want exactly-once-ish semantics via the transactional outbox pattern? Replace MessengerEventBus with an OutboxEventBus that writes events to a database table inside the same transaction as the aggregate, and a separate worker polls the table and dispatches into Messenger. The use case still calls $this->events->publish($order->pullEvents()). The transactional guarantee is an adapter concern.
The pitfalls to dodge
Domain events that carry framework types. If your OrderPlaced has a Symfony\Component\Uid\Uuid field, you have just imported Symfony into the domain. Use string (or a domain-owned OrderId value object) and let the adapter convert. Same for DateTimeInterface vs Carbon — pick one, keep it boring, prefer the standard library.
Handlers that touch repositories directly. A consumer handler that calls $this->orderRepo->save() re-couples the read side to the write side. Handlers should either call domain services through their own ports, or be projection-only (write into a read-model table the domain does not know about).
Publishing inside the aggregate constructor. Resist. The aggregate records events; the application service publishes them. If the constructor publishes, you cannot test the aggregate without a bus, and you cannot use the same aggregate inside a replay or migration script that should not re-emit events.
Eager retries without idempotency. Messenger's retry strategy will redeliver. If your SendOrderConfirmationOnOrderPlaced handler sends an email and the broker retries the same message, the customer gets two emails. Make handlers idempotent — key them on eventId() and de-duplicate at the consumer.
Treating the failure transport as a graveyard. failed is a queue, not a corner of the database. Wire a daily job that lists it, alerts on growth, and either replays or discards messages with a recorded reason. A growing failure queue with no owner is how you find out about a broken consumer three weeks late.
The shape you end up with
The dependency direction is the whole point. The domain knows nothing about transports, the application layer knows the domain and its ports, and the infrastructure ring is where Messenger finally gets to exist. Swap the infrastructure ring out and the rest of the application is untouched.
The same codebase runs synchronously in tests (Messenger's sync:// transport bypasses queues entirely), in a single-process Docker setup with doctrine:// as the transport (rows in a messenger_messages table, polled by the worker), and in production with AMQP, without anything in the domain or application layer changing.
Messenger is a fine library. It is also a fine adapter. It should not be your domain.
If this was useful
The full version of this pattern — outbox, projections, the read/write split, idempotent consumers, and the migration playbook for stripping Messenger out of domain code that already grew into it — is what Decoupled PHP walks through end to end. The book treats Laravel and Symfony as adapters, not protagonists, and shows the same shape on Doctrine, AMQP, Redis, and HTTP.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)