Event-Driven Architecture with Symfony Messenger โ
Symfony Messenger is one of the components I reach for on almost every backend project. It gives you a message bus, async transports, retries and a worker in one package. The basics take an afternoon. Running it reliably in production takes a bit more thought. This post covers the parts that matter once real traffic arrives. Everything here is valid for Symfony 7.x and 8.x.
๐งญ Commands vs Events โ
Before touching config, get the vocabulary right. It changes how you design handlers.
- A command is an instruction:
PlaceOrder. It has exactly one handler, and if it fails, the caller should know. - An event is a fact:
OrderPlaced. It has zero or more handlers, and the publisher shouldn't care who's listening.
I like to give them separate buses. The event bus is allowed to have no handlers, and the command bus wraps each handler in a Doctrine transaction:
# config/packages/messenger.yaml
framework:
messenger:
default_bus: command.bus
buses:
command.bus:
middleware:
- doctrine_transaction
event.bus:
default_middleware:
enabled: true
allow_no_handlers: trueSymfony then autowires MessageBusInterface $eventBus to the event bus. You can also pin a handler to a bus with #[AsMessageHandler(bus: 'event.bus')].
๐ Choosing a Transport โ
Every transport is a separate package, and each is configured with a DSN:
| Transport | Package | Example DSN |
|---|---|---|
| Doctrine | symfony/doctrine-messenger | doctrine://default |
| Redis | symfony/redis-messenger | redis://localhost:6379/messages |
| AMQP (RabbitMQ) | symfony/amqp-messenger | amqp://guest:guest@localhost:5672/%2f/messages |
| Amazon SQS | symfony/amazon-sqs-messenger | https://sqs.eu-west-2.amazonaws.com/123456789012/messages |
My rule of thumb:
- Doctrine is underrated. It needs no extra infrastructure, and it shares your database transaction, which matters for the outbox pattern below.
- Redis is quick to set up if you already run it.
- AMQP is the choice when you need real routing, with exchanges and fan-out.
- SQS is the obvious pick on AWS. Be careful with FIFO queues: they don't support per-message delays, so the retry strategy needs
delay: 0.
๐ Retries and Failure Transports โ
Handlers will fail. APIs time out and deadlocks happen. Messenger retries failed messages per transport, with exponential backoff. Once the retries run out, the message goes to a failure transport instead of disappearing:
framework:
messenger:
failure_transport: failed
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000 # ms before the first retry
multiplier: 2 # 1s, 2s, 4s...
max_delay: 60000
jitter: 0.1 # Symfony 7.1+
outbox: 'doctrine://default?queue_name=outbox'
failed: 'doctrine://default?queue_name=failed'
routing:
'App\Message\PlaceOrder': async
'App\Message\OrderPlaced': outboxSome failures will never succeed, such as invalid data or a deleted entity. For those, throw UnrecoverableMessageHandlingException to skip the retries entirely. Then make inspecting the failure transport part of your routine. messenger:failed:show lists what failed and why, and messenger:failed:retry lets you replay messages once the cause is fixed.
๐งท Idempotency and the Outbox Pattern โ
Two hard truths about async messaging:
- Delivery is at-least-once. A worker can crash after doing the work but before acknowledging the message, so it gets delivered again.
- Dual writes are dangerous. If you commit an order to the database and then publish
OrderPlacedto a broker, a failure between the two leaves you with an order that no one ever hears about.
The outbox pattern fixes the second problem. You write the event into your own database in the same transaction as the business data, and relay it afterwards. With Messenger, you get this almost for free. Route the event to a Doctrine transport on the same connection. With doctrine_transaction on the command bus, the order and the queued event are committed or rolled back together:
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Uid\Uuid;
#[AsMessageHandler(bus: 'command.bus')]
final class PlaceOrderHandler
{
public function __construct(
private EntityManagerInterface $em,
private MessageBusInterface $eventBus,
) {}
public function __invoke(PlaceOrder $command): void
{
$this->em->persist(Order::place($command->orderId, $command->customerId));
// Inserted into messenger_messages inside the same transaction
$this->eventBus->dispatch(new OrderPlaced(
(string) Uuid::v7(), $command->orderId, $command->customerId,
));
}
}A worker consuming outbox then runs the event handlers. If other services need the event, one of those handlers forwards it to SQS or RabbitMQ.
That still leaves the first problem, so handlers must be idempotent. Symfony 7.3 added a DeduplicateStamp, which prevents duplicate dispatches while a message is queued or being processed. It's about duplicate dispatches, not redelivery after a crash. For that, I record each processed event ID under a unique key, in the same transaction as the side effect:
// In an #[AsMessageHandler(bus: 'event.bus')] class with Doctrine\DBAL\Connection injected as $this->db
public function __invoke(OrderPlaced $event): void
{
$this->db->transactional(function (Connection $db) use ($event): void {
// PostgreSQL syntax; a unique key on (event_id, handler) makes redelivery a no-op
$claimed = $db->executeStatement(
'INSERT INTO processed_messages (event_id, handler) VALUES (?, ?) ON CONFLICT DO NOTHING',
[$event->eventId, self::class],
);
if (0 === $claimed) {
return;
}
$db->executeStatement(
'UPDATE customer_stats SET order_count = order_count + 1 WHERE customer_id = ?',
[$event->customerId],
);
});
}โ๏ธ Running Workers in Production โ
messenger:consume is a long-running PHP process, and long-running PHP processes leak memory. Never let a worker run forever. Give it limits and let a process manager restart it:
php bin/console messenger:consume async outbox --time-limit=3600 --memory-limit=256M --limit=1000List receivers in priority order, because the worker always drains the first one before the next. On every deploy, run messenger:stop-workers after the cache warm-up. Each worker finishes its current message and exits, and comes back on the new code.
On a VM, Supervisor or systemd does the restarting:
;/etc/supervisor/conf.d/messenger-worker.conf
[program:messenger-consume]
command=php /var/www/app/bin/console messenger:consume async outbox --time-limit=3600
numprocs=2
autostart=true
autorestart=true
stopwaitsecs=20
process_name=%(program_name)s_%(process_num)02dIn containers, I run one worker process per container and let the orchestrator handle restarts and scaling. Install the pcntl extension so workers catch SIGTERM and finish the current message before exiting. Then set the container's grace period longer than your slowest handler. If you use Redis, give every consumer a unique MESSENGER_CONSUMER_NAME. The container's hostname works well.
๐ฏ Conclusion โ
Messenger makes event-driven Symfony approachable, but the defaults only get you so far. Separate commands from events and pick a transport for a reason. Configure retries and actually watch the failure transport. Assume every message arrives twice. If you get the outbox and idempotency right, most of the scary failure modes in async systems simply go away.