Combining Messenger and Event Bus
A user registers, the welcome email must be sent asynchronously, the CRM entry created, and the analytics updated. At the same time, the domain should fire an event that reacts synchronously to the same process. Symfony Messenger and the Event Dispatcher can both do this, once you understand how they work together.
Table of Contents
- 1. Messenger vs. Event Dispatcher: two concepts, one goal
- 2. Setting up Symfony Messenger: transports and routing
- 3. Messages and handlers: type-safe and decoupled
- 4. Asynchronous processing: queues and workers
- 5. Building the event bus with Messenger
- 6. Firing domain events synchronously, processing async
- 7. Retry strategies and the failure transport
- 8. Middleware for logging, tracing and transactions
- 9. Direct comparison: Messenger patterns at a glance
- 10. Summary
- 11. FAQ
1. Messenger vs. Event Dispatcher: two concepts, one goal
Many Symfony developers use Symfony Messenger and the Event Dispatcher synonymously, yet both have distinct strengths and use cases. The Event Dispatcher is synchronous: an event fires, all listeners run immediately within the current HTTP request lifecycle, before the code continues. That makes it ideal for domain events where the outcome of a listener affects the further flow, for example a listener aborting an operation. Symfony Messenger, by contrast, is designed for decoupling and optional asynchrony: a message is placed on a queue, and a worker process handles it later, independent of the original HTTP request.
The decisive architectural advantage of Symfony Messenger: a message can be processed synchronously or asynchronously depending on the routing configuration, without changing the handler code at all. That enables gradual asynchronization: in development everything runs synchronously in the same process, while in production compute-intensive or fault-tolerant operations are offloaded to the queue. The best of both worlds emerges when you configure Symfony Messenger as an event bus: domain events are dispatched through Messenger and, depending on the event type, processed synchronously or asynchronously.
2. Setting up Symfony Messenger: transports and routing
Symfony Messenger is configured via config/packages/messenger.yaml. The central concept is the transport: it defines how and where messages are stored, whether as an AMQP queue in RabbitMQ, as a database row via Doctrine, as a Redis stream, or in memory for synchronous tests. Every transport has a DSN containing the connection parameters. Routing maps message classes to transports: when a SendWelcomeEmailMessage arrives, Symfony Messenger sends it to the async transport. When a CriticalAlertMessage arrives, it goes to a prioritized high-priority transport.
Multiple transports can run in parallel, allowing for differentiated prioritization: emails on a slow, fault-tolerant transport with many retry attempts, critical notifications on a fast transport with a single worker that exclusively consumes that queue. With the Doctrine transport, Symfony Messenger also supports a transactional queue: the message is stored in the same database transaction as the main operation. If the transaction rolls back, the message disappears too, so there are no more orphaned queue entries for entities that were never saved.
# config/packages/messenger.yaml
framework:
messenger:
# Failure transport: failed messages land here after all retries
failure_transport: failed
transports:
# Async processing via Doctrine DB table, transactional!
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000 # 1 second initial delay
multiplier: 2 # exponential backoff: 1s, 2s, 4s
max_delay: 60000 # cap at 60 seconds
# High-priority transport, separate worker, low retry count
high_priority:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%?queue_name=high_priority'
retry_strategy:
max_retries: 1
# Failed messages storage for inspection and manual retry
failed:
dsn: 'doctrine://default?queue_name=failed'
routing:
# Route by fully-qualified class name
App\Message\SendWelcomeEmailMessage: async
App\Message\GenerateInvoicePdfMessage: async
App\Message\CriticalAlertMessage: high_priority
# Wildcard routing for all domain events
App\Domain\Event\*: async
3. Messages and handlers: type-safe and decoupled
A message in Symfony Messenger is a plain PHP class with no dependencies, a value object carrying all the data needed for processing. No interfaces, no base classes, no Symfony dependencies: a message is a pure data envelope. Constructor property promotion keeps messages compact and type-safe in PHP 8.x. Important: messages are serialized and stored, which means they should not contain non-serializable objects such as Doctrine entities or services. Instead, pass IDs and reload the entity from the database inside the handler.
Handlers are services carrying the #[AsMessageHandler] attribute. Symfony Messenger finds and registers them automatically via service discovery. A handler can have several handle methods, each responsible for a different message type, which allows grouping related processing logic in one class without creating a god class. Multiple handlers for the same message are possible: both are invoked on every dispatch. That mirrors the observer pattern from the event bus world, but with persistent queue semantics instead of synchronous execution.
<?php
declare(strict_types=1);
namespace App\Message;
// Immutable message, only scalar types and value objects, no Doctrine entities
final readonly class SendWelcomeEmailMessage
{
public function __construct(
public readonly int $userId,
public readonly string $email,
public readonly string $locale,
) {}
}
// ---------------------------------------------------------------------------
namespace App\MessageHandler;
use App\Message\SendWelcomeEmailMessage;
use App\Repository\UserRepository;
use App\Service\Mailer\WelcomeMailer;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Handles welcome email dispatch after user registration.
* Runs asynchronously via the 'async' transport in production.
*/
#[AsMessageHandler]
final readonly class SendWelcomeEmailHandler
{
public function __construct(
private UserRepository $userRepository,
private WelcomeMailer $mailer,
private LoggerInterface $logger,
) {}
/**
* Load the user fresh from DB, never pass entities through the queue.
*/
public function __invoke(SendWelcomeEmailMessage $message): void
{
$user = $this->userRepository->find($message->userId);
if ($user === null) {
// User deleted between dispatch and processing, not an error
$this->logger->info('User {id} not found, skipping welcome email.', [
'id' => $message->userId,
]);
return;
}
$this->mailer->sendWelcome($user, $message->locale);
$this->logger->info('Welcome email sent to {email}.', ['email' => $message->email]);
}
}
4. Asynchronous processing: queues and workers
The worker process that handles queue messages is started with bin/console messenger:consume async. In production, this process runs permanently under a process manager such as Supervisor or systemd, as Symfony Messenger does not ship its own daemon infrastructure. The --limit flag caps the number of messages processed per worker run, and --time-limit caps the runtime in seconds. Both options prevent memory leaks from long-running PHP processes: the process manager restarts the worker automatically once it exits.
Status monitoring happens with bin/console messenger:stats, which shows queue depths and failed messages. Symfony Messenger integrates with the Symfony Profiler: in development mode, the profiler shows every dispatched message, its handler, and the processing time. Failed messages land on the failed transport once all retry attempts are exhausted and can be inspected and manually reprocessed with bin/console messenger:failed:show and messenger:failed:retry. That is considerably more transparent than silently discarding failed queue jobs.
5. Building the event bus with Messenger
Symfony Messenger can be configured as a fully-fledged event bus. The difference from a regular message: an event can have several handlers, and all of them should run, in contrast to a command, which should have exactly one handler. Configuration happens via the HandleMessageMiddleware with allow_no_handlers: true and multiple handlers registered for the same event type. Symfony recommends configuring the event bus and command bus as separate Messenger instances to enforce the different rules (exactly one handler vs. zero to many handlers).
The event bus in Symfony Messenger combines the strengths of both worlds: events can be processed asynchronously, land on a persistent queue, benefit from retry logic, and are decoupled from the HTTP request lifecycle. At the same time, type safety through PHP classes is preserved, with no string-based event system, no magic methods, and no dynamic event routing. The Symfony Profiler shows every dispatched event, every invoked handler and the processing times. That makes event-driven architectures built with Symfony Messenger considerably easier to debug than traditional event bus implementations.
6. Firing domain events synchronously, processing async
The most elegant pattern combines the Symfony Event Dispatcher for synchronous domain events with Symfony Messenger for asynchronous side effects. A domain event such as UserRegisteredEvent is fired synchronously and received by a listener that dispatches a Symfony Messenger message. The actual email sending, CRM update, and analytics tracking then happen asynchronously in the worker. The result: the HTTP response is returned immediately without waiting on external services, while the side effects are guaranteed by queue persistence.
Symfony 6.2 introduced the concept of dispatching events directly through Symfony Messenger, without an explicit dispatch listener. With the DispatchAfterCurrentBusMiddleware middleware, messages dispatched while another message is being processed are only dispatched after the main processing completes. That prevents race conditions and ensures all database operations are committed before an event reaches the queue. This middleware pattern is the cleanest solution for the classic problem: dispatching an event that reads data from the database before the transaction is committed.
<?php
declare(strict_types=1);
namespace App\Domain\Event;
// Domain event, fired synchronously in the application service
final readonly class UserRegisteredEvent
{
public function __construct(
public readonly int $userId,
public readonly string $email,
public readonly \DateTimeImmutable $registeredAt,
) {}
}
// ---------------------------------------------------------------------------
namespace App\EventListener;
use App\Domain\Event\UserRegisteredEvent;
use App\Message\SendWelcomeEmailMessage;
use App\Message\CreateCrmContactMessage;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* Bridges synchronous domain events to async Messenger messages.
* This listener runs synchronously; actual work happens in workers.
*/
#[AsEventListener(event: UserRegisteredEvent::class)]
final readonly class UserRegisteredListener
{
public function __construct(
private MessageBusInterface $bus,
) {}
public function __invoke(UserRegisteredEvent $event): void
{
// Dispatch async messages, they are queued, not executed immediately
$this->bus->dispatch(new SendWelcomeEmailMessage(
userId: $event->userId,
email: $event->email,
locale: 'en',
));
$this->bus->dispatch(new CreateCrmContactMessage(
userId: $event->userId,
email: $event->email,
));
}
}
7. Retry strategies and the failure transport
Network failures, outages of external services, and transient database problems are the rule in asynchronous systems, not the exception. Symfony Messenger implements automatic retry logic with configurable exponential backoff: if a handler fails with an exception, the message is reprocessed after a growing wait time. The wait time doubles with each attempt, 1 second, 2 seconds, 4 seconds, up to the configured maximum. This strategy gives external services time to recover without blocking the worker.
Not every exception should trigger a retry. Validation errors, missing entities, or business logic violations are permanent failures that will not disappear even after ten attempts. Symfony Messenger supports the UnrecoverableMessageHandlingException concept: when this exception is thrown, Messenger skips all further retry attempts and moves the message straight to the failed transport. That saves queue resources and prevents known-broken messages from occupying retry slots for hours. Custom retry strategies allow even finer control: you can exclude specific exception classes from retries or adjust the delay formula.
8. Middleware for logging, tracing and transactions
The middleware pipeline of Symfony Messenger wraps every dispatch and handle operation. Each middleware receives the message, can transform or enrich it, calls the next middleware, and processes the result. That makes middleware the ideal place for cross-cutting concerns: logging every message with correlation IDs, tracing spans for distributed tracing with OpenTelemetry, database transactions that are automatically committed or rolled back depending on the handler's outcome.
The DoctrineTransactionMiddleware from the symfony/doctrine-messenger package is a prime example: it wraps every handler call in a Doctrine transaction. If the handler fails, the transaction rolls back automatically, and all database operations performed by the handler disappear as if they had never happened. For Symfony Messenger queues built on Doctrine, that means message processing and database operations are transactionally linked. Anyone writing custom middleware implements MiddlewareInterface with a single handle() method and registers the middleware in the service container.
| Pattern | Transport | Handler count | Typical use case |
|---|---|---|---|
| Command Bus | sync or async | Exactly 1 | State-changing commands |
| Event Bus | async | 0 to many | Domain events, side effects |
| Query Bus | sync (always) | Exactly 1 | Read queries with return value |
| Priority Queue | high_priority async | Exactly 1 | Time-critical notifications |
| Event Dispatcher | sync (always) | 0 to many | HTTP events, kernel events |
9. Direct comparison: Messenger patterns at a glance
The table shows how different messaging patterns differ within Symfony Messenger. Command Bus, Event Bus and Query Bus are three distinct responsibilities that can all be modeled through Symfony Messenger, but they carry different configurations and expectations. The Query Bus is a special case: it must always run synchronously because the caller waits for a result. Routing it to an asynchronous transport would break the application, so a dedicated query bus without async routing is recommended.
For teams just getting started with Symfony Messenger, the pragmatic entry point is: a single bus for all messages, synchronous in development mode, an async transport in production for all long-running operations. The split into Command Bus, Event Bus and Query Bus can be introduced gradually as requirements grow more complex. Routing in messenger.yaml is the only place that needs to change; handler code stays untouched when a message moves from sync to async.
Mironsoft
Symfony backend architecture, Messenger integration and event-driven systems
Building an asynchronous Symfony architecture?
We design and implement event-driven Symfony systems with Messenger, from message architecture through worker infrastructure to retry strategies and monitoring for your production environment.
Architecture
Command/Event/Query bus separation and message design for scalable Symfony systems
Worker infrastructure
Supervisor/systemd setup, monitoring and deployment strategy for Messenger workers
Fault tolerance
Retry strategies, failure transport and alerting for reliable message processing
10. Summary
Symfony Messenger and the Event Dispatcher solve related but distinct problems. The Event Dispatcher fires synchronous domain events that are processed immediately within the same request. Symfony Messenger persists messages on a queue and processes them asynchronously in worker processes, with automatic retry, a failure transport, and configurable exponential backoff. The strongest architectural pattern combines both: domain events are fired synchronously, and listeners dispatch asynchronous Messenger messages for side effects such as emails, CRM updates, and analytics.
The DoctrineTransactionMiddleware links message processing and database operations transactionally. UnrecoverableMessageHandlingException prevents pointless retry loops for permanent failures. Separate transports for different priorities enable differentiated SLA guarantees. Anyone who applies Symfony Messenger consistently builds a system where HTTP requests respond quickly while compute-intensive, fault-tolerant operations run reliably in the background.
Symfony Messenger + Event Bus, the essentials at a glance
Async without code changes
Routing in messenger.yaml determines sync/async, handler code stays unchanged. Gradual asynchronization without refactoring.
Retry & failure transport
Exponential backoff with max_retries and delay. Failed messages land on the failed transport for manual inspection.
Domain events + Messenger
Event Dispatcher fires synchronously, listeners dispatch Messenger messages for asynchronous side effects. Best of both worlds.
Transactional queue
Doctrine transport + DoctrineTransactionMiddleware: message dispatch and DB operations in one transaction, no orphaned queue entries.