Symfony Messenger: Event-Driven Architecture from Scratch
AI generated
SF
{ }
Symfony · Messenger · Event-Driven · CQRS
Symfony Messenger: Event-Driven
Architecture from Scratch

Synchronous systems scale up to a point, then HTTP requests grow too long, database connections pile up, and a single failing service blocks the entire system. Symfony Messenger solves exactly this problem through a clear separation of command, event and query, combined with asynchronous transports that deliver messages reliably even during system outages.

20 min read Messages · Handlers · Transports · Middlewares · Domain Events Symfony 7.x · PHP 8.3+ · RabbitMQ · Redis

1. Why Event-Driven Architecture with Symfony Messenger?

An Event-Driven Architecture built with Symfony Messenger solves a fundamental scaling problem: when a customer places an order, the system must simultaneously persist the order, send a confirmation email, update stock levels and trigger a CRM event. Done synchronously within a single HTTP request, each of these steps extends the response time. If one fails, the entire transaction fails. Symfony Messenger decouples these steps: the order is saved, a message is placed on a queue, and every further step runs asynchronously in separate worker processes.

The second benefit of Event-Driven Architecture is the structural decoupling of system components. An invoicing module does not need to know anything about the notification module, it dispatches an OrderPlacedEvent, and every interested handler reacts to it independently. New requirements such as a loyalty points system can be added by registering one more handler, without touching existing code. Symfony Messenger implements this pattern with a message bus that forwards messages to registered handlers while transparently managing transports, middlewares and retry strategies.

2. Messages, commands, queries and events, the basic concepts

In an Event-Driven Architecture with Symfony Messenger there are three fundamentally different message types, each with its own semantics. A Command is an intent: PlaceOrderCommand carries all the data needed to place an order. Commands have exactly one handler and change state. A Query reads data without side effects: GetOrderByIdQuery returns an order without changing anything. Queries are processed synchronously because the caller needs the result immediately. An Event describes what happened: OrderPlacedEvent tells the system that an order has arrived, without knowing or deciding what happens next.

This separation is the core of CQRS (Command Query Responsibility Segregation) and can be implemented directly with Symfony Messenger. The MessageBusInterface is the central entry point: $this->commandBus->dispatch(new PlaceOrderCommand(...)). Symfony allows multiple bus instances, so you can configure a command bus, a query bus and an event bus separately, each with its own middlewares and routing rules. A command bus enforces that every message has exactly one handler. An event bus allows zero or more handlers. The query bus returns the handler's return value. Separating these three buses is not overengineering, it creates clarity of intent in the code.


<?php

declare(strict_types=1);

namespace App\Order\Application\Command;

// Command, immutable value object carrying intent
final readonly class PlaceOrderCommand
{
    /**
     * @param list<array{productId: int, quantity: int}> $items
     */
    public function __construct(
        public readonly int $customerId,
        public readonly array $items,
        public readonly string $shippingAddress,
    ) {}
}

// Command Handler, single responsibility: place the order and dispatch events
namespace App\Order\Application\Command;

use App\Order\Domain\Event\OrderPlacedEvent;
use App\Order\Domain\Repository\OrderRepositoryInterface;
use App\Order\Domain\Service\OrderFactory;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;

#[AsMessageHandler]
final class PlaceOrderCommandHandler
{
    public function __construct(
        private readonly OrderRepositoryInterface $orderRepository,
        private readonly OrderFactory $orderFactory,
        private readonly MessageBusInterface $eventBus,
    ) {}

    /**
     * Handle the PlaceOrderCommand: create, persist and publish order placed event.
     */
    public function __invoke(PlaceOrderCommand $command): void
    {
        $order = $this->orderFactory->createFromCommand($command);
        $this->orderRepository->save($order, flush: true);

        // Dispatch domain event, all interested handlers react independently
        $this->eventBus->dispatch(new OrderPlacedEvent(
            orderId: $order->getId(),
            customerId: $command->customerId,
            totalAmount: $order->getTotalAmount(),
            placedAt: new \DateTimeImmutable(),
        ));
    }
}

3. Handlers: processing messages and encapsulating logic

A Symfony Messenger handler is a PHP class with an __invoke method that accepts a specific message type. The #[AsMessageHandler] attribute automatically registers the class as a service and connects it to the matching message type via the type hint. No YAML, no interface implementation, the type of the parameter determines which message the handler is responsible for. Multiple handlers for the same message type are possible when the bus is configured as an event bus. On a command bus, registering a second handler for the same type would result in an error.

Handlers should stay thin: they coordinate domain services, call repositories and dispatch further messages, but they contain no business logic of their own. The logic belongs in the domain classes. A SendOrderConfirmationHandler calls a MailerService, which renders the actual template and sends the mail. This separation makes handlers testable: the handler is tested with a mock of the MailerService, and the service itself with a separate integration test. Symfony Messenger injects all of the handler's dependencies automatically through the DI container, and constructor property promotion makes those dependencies explicit and compact.

4. Transports: asynchronous delivery with RabbitMQ and Redis

A transport in Symfony Messenger is the connection to an external message broker or a queue implementation. AMQP for RabbitMQ, Redis Streams, Doctrine as a database queue and Amazon SQS are all available as transports. The default transport without any configuration is synchronous, the message is processed immediately within the same request. That is useful for development and testing, but in production you want asynchronous transports that place messages on a queue and leave them for worker processes to handle asynchronously.

RabbitMQ over AMQP is the most reliable transport for production Event-Driven Architecture setups. AMQP supports exchanges, routing keys and queue bindings that map directly onto the Symfony configuration. Redis Streams are easier to set up and sufficient for moderate load scenarios. Doctrine as a transport is a pragmatic choice for teams without dedicated message broker infrastructure: messages end up in a database table, and worker processes poll and process them. The downside is that the database becomes a bottleneck under high load. For starter projects with low message volume, however, Doctrine is perfectly legitimate and requires no additional infrastructure.


# config/packages/messenger.yaml
# Symfony Messenger transport and routing configuration

framework:
  messenger:
    # Multiple buses: command, query and event bus with individual middlewares
    default_bus: command.bus

    buses:
      command.bus:
        middleware:
          - App\Messenger\Middleware\CommandLoggingMiddleware
      query.bus:
        middleware: []
      event.bus:
        default_middleware:
          enabled: true
          allow_no_handlers: true  # Events may have zero handlers

    transports:
      # Async AMQP transport for order-related messages
      orders_async:
        dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
        options:
          exchange:
            name: orders
            type: topic
          queues:
            orders_high_priority:
              binding_keys: ['order.#']

      # Redis Streams transport for notification events
      notifications_async:
        dsn: 'redis://localhost:6379/messages'
        options:
          stream: notifications
          group: symfony

      # Doctrine-backed failure transport, stores failed messages for retry
      failed:
        dsn: 'doctrine://default?queue_name=failed'

    routing:
      # Commands routed to orders queue
      App\Order\Application\Command\PlaceOrderCommand: orders_async
      App\Inventory\Application\Command\ReserveStockCommand: orders_async

      # Events routed to notifications queue
      App\Order\Domain\Event\OrderPlacedEvent: notifications_async
      App\Customer\Domain\Event\CustomerRegisteredEvent: notifications_async

    failure_transport: failed

5. Routing: which message goes where?

Routing in Symfony Messenger determines through which transport a message is dispatched. The configuration under framework.messenger.routing maps message classes onto transport names. Without a routing entry, the message is processed synchronously. That is a deliberate default: not every message needs to be asynchronous. Queries should always be processed synchronously because the caller needs the result immediately. Commands and events can be routed synchronously or asynchronously depending on priority and dependencies.

A common pattern in Event-Driven Architecture projects is using multiple transports with different priorities. Critical commands such as payment processing land in a high priority queue with little parallelism but immediate processing. Less time-critical events such as statistics updates land in a low priority queue that is processed with a delay. Symfony Messenger allows multiple worker processes per transport and multiple transports within the same worker invocation: php bin/console messenger:consume orders_async notifications_async --limit=500 processes both transports in one process and exits after 500 messages, ideal for Supervisor or systemd.

6. Middlewares: logging, tracing and idempotency

Middlewares in Symfony Messenger are classes that wrap every message dispatch and implement cross-cutting concerns. A logging middleware writes the message type and processing duration to the application log. A tracing middleware creates OpenTelemetry spans that make the entire path of a message through the system traceable, from the HTTP request through the command bus into the async queue and all the way to the worker. An idempotency middleware checks a message ID to see whether a message has already been processed and skips it on duplicates. This is especially important in retry scenarios, where a message can be delivered more than once.

The built-in middlewares of Symfony Messenger handle Doctrine transactions automatically: the DoctrineTransactionMiddleware wraps every handler in a transaction that is committed on success and rolled back on exception. This prevents a half-processed order from ending up in the database. The ValidationMiddleware validates the message against Symfony Validator constraints before the handler is called, rejecting faulty input data before it ever reaches a queue. Middlewares are configured as services and referenced in the bus definition.


<?php

declare(strict_types=1);

namespace App\Messenger\Middleware;

use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
use Symfony\Component\Messenger\Middleware\StackInterface;
use Symfony\Component\Messenger\Stamp\HandledStamp;

/**
 * Middleware that logs every message dispatch with duration and result.
 */
final readonly class CommandLoggingMiddleware implements MiddlewareInterface
{
    public function __construct(
        private LoggerInterface $logger,
    ) {}

    /**
     * Log the message class, duration and handler result.
     */
    public function handle(Envelope $envelope, StackInterface $stack): Envelope
    {
        $messageClass = $envelope->getMessage()::class;
        $startTime = hrtime(true);

        $this->logger->info('Dispatching message', ['message' => $messageClass]);

        try {
            $envelope = $stack->next()->handle($envelope, $stack);

            $durationMs = (hrtime(true) - $startTime) / 1_000_000;

            // Check if handled synchronously (HandledStamp is only added for sync handling)
            $handledStamp = $envelope->last(HandledStamp::class);

            $this->logger->info('Message handled successfully', [
                'message'     => $messageClass,
                'duration_ms' => round($durationMs, 2),
                'handled_by'  => $handledStamp?->getHandlerName() ?? 'async transport',
            ]);

            return $envelope;
        } catch (\Throwable $e) {
            $durationMs = (hrtime(true) - $startTime) / 1_000_000;
            $this->logger->error('Message handling failed', [
                'message'     => $messageClass,
                'duration_ms' => round($durationMs, 2),
                'error'       => $e->getMessage(),
            ]);
            throw $e;
        }
    }
}

7. Retry, failure transport and dead letter queue

Network errors, temporary database outages and external API timeouts are unavoidable in asynchronous systems. Symfony Messenger has a built-in retry strategy that redelivers failed messages after a configurable wait time. The default strategy is exponential backoff: wait 1 second after the first failure, 2 seconds after the second, 4 seconds after the third, up to a configured maximum number of attempts. After the final failed attempt, the message lands in the failure transport, which is configured separately and acts as a dead letter queue.

The failure transport stores failed messages together with their error context: exception class, stack trace, number of attempts and timestamp. The command php bin/console messenger:failed:show lists all failed messages. php bin/console messenger:failed:retry 42 redelivers a specific message. Tools such as Datadog, Grafana with Prometheus, or a simple dashboard that reads the Doctrine failure transport's table are well suited for monitoring failure queues. Important: not every error is temporary. Symfony Messenger lets you exclude specific exception types from the retry logic, since a validation error caused by invalid input data will not resolve itself even after ten retries.

8. Dispatching domain events from Doctrine entities

Domain events are the heart of a genuine Event-Driven Architecture: an entity internally maintains a list of recorded events and hands them off to the Symfony Messenger bus after being successfully persisted. The pattern is called the "outbox pattern" or "collected events": the entity does not call any external service, it merely notes what happened. The dispatcher, typically a Doctrine event subscriber, collects all entities with pending events after the flush, dispatches them and clears the list.

This pattern solves a classic problem in Event-Driven Architecture: if the event is dispatched before the flush and the flush then fails afterward, handlers have already reacted, but the state change was never persisted. Conversely, if the flush succeeds and the dispatch fails afterward, the state was changed but nobody reacted. Dispatching in Doctrine's postFlush hook guarantees that events are only handed to Symfony Messenger after successful persistence. For absolute reliability you can add the outbox pattern in the database on top, but for most use cases the post-flush approach is enough.


<?php

declare(strict_types=1);

namespace App\Shared\Domain;

// Trait for recording domain events, add to any Doctrine Entity
trait RecordsEvents
{
    /** @var list<object> */
    private array $recordedEvents = [];

    /**
     * Record a domain event to be dispatched after successful persistence.
     */
    protected function recordEvent(object $event): void
    {
        $this->recordedEvents[] = $event;
    }

    /**
     * Pull and clear all recorded events.
     *
     * @return list<object>
     */
    public function releaseEvents(): array
    {
        $events = $this->recordedEvents;
        $this->recordedEvents = [];
        return $events;
    }
}

// Doctrine Event Subscriber, dispatches domain events after successful flush
namespace App\Shared\Infrastructure\Doctrine;

use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PostFlushEventArgs;
use Doctrine\ORM\Events;
use App\Shared\Domain\RecordsEvents;
use Symfony\Component\Messenger\MessageBusInterface;

#[AsDoctrineListener(Events::postFlush)]
final class DomainEventDispatcher
{
    public function __construct(
        private readonly MessageBusInterface $eventBus,
    ) {}

    /**
     * Collect domain events from all managed entities and dispatch them via the event bus.
     */
    public function postFlush(PostFlushEventArgs $args): void
    {
        $unitOfWork = $args->getObjectManager()->getUnitOfWork();

        foreach ($unitOfWork->getIdentityMap() as $entityClass => $entities) {
            foreach ($entities as $entity) {
                if (!method_exists($entity, 'releaseEvents')) {
                    continue;
                }

                foreach ($entity->releaseEvents() as $event) {
                    $this->eventBus->dispatch($event);
                }
            }
        }
    }
}

9. Symfony Messenger vs. direct service calls

A direct comparison between direct service calls and Symfony Messenger shows where the message bus brings real benefits and where it creates overhead. The decision depends on system complexity, scaling requirements and team experience.

Aspect Direct service calls Symfony Messenger Recommendation
Decoupling Direct dependency between services Handlers do not know each other Messenger as complexity grows
Async processing Manual: queues, workers, retry Transport + worker built in Messenger when async is needed
Debugging Simple stack trace Inspect failure transport and stamps Direct calls for simple services
Testability Mock of the called service InMemoryTransport + assertDispatched Both are well testable
Retry on failure Implement manually Exponential backoff built in Messenger for external calls

The table shows: Symfony Messenger clearly wins on asynchronous processing, retry logic and decoupling. For simple synchronous operations that always run in the same request and need no retry semantics, a direct service call is clearer and easier to debug. Many Symfony projects use both: Messenger for commands that have side effects and can be handled asynchronously, and direct calls for services that must run within the same request.

Mironsoft

Symfony Messenger, Event-Driven Architecture and scalable backend systems

Want to build an Event-Driven Architecture with Symfony Messenger?

We design and implement Event-Driven Architectures with Symfony Messenger, from bus configuration through domain events and transports to a complete monitoring infrastructure for your stack.

Architecture design

Command/query/event separation, bus configuration and transport strategy for your project

Domain events

Outbox pattern, Doctrine integration and reliable event delivery even during system outages

Monitoring

Failure queue dashboard, retry strategies and alerting for failed messages set up for you

10. Summary

Symfony Messenger is the foundation for a production-ready Event-Driven Architecture in PHP. The clear separation into commands, queries and events, each with its own semantic rules and its own bus, creates clarity in the code and flexibility in scaling. Transports abstract away the broker behind them: whether RabbitMQ, Redis or Doctrine, the handler code stays identical. Middlewares encapsulate cross-cutting concerns such as logging, tracing and idempotency once for all messages. Retry strategies and failure transports make the system resilient against temporary failures.

Connecting domain events from Doctrine entities with the post-flush dispatcher is the pattern that keeps state changes and their consequences consistent. New requirements are implemented through new handlers, without touching existing code. Tests benefit from the InMemoryTransport, which checks whether the right messages were dispatched without any external queue infrastructure. Symfony Messenger is thus not just a technical tool but an architectural pattern that makes systems more maintainable, more testable and more scalable.

Symfony Messenger & Event-Driven Architecture, the essentials at a glance

Three bus types

Configure command bus (1 handler), query bus (return value) and event bus (0 to n handlers) separately, creating clarity of intent.

Async transports

RabbitMQ, Redis Streams or Doctrine as queue backend, routing in messenger.yaml determines which message uses which transport.

Retry & failure

Exponential backoff built in. Failure transport acts as a dead letter queue. messenger:failed:retry for manual redelivery.

Domain events

RecordsEvents trait in the entity plus a post-flush dispatcher guarantees that events are only dispatched after successful persistence, for consistent system state.

11. FAQ: Symfony Messenger and Event-Driven Architecture

1What is Symfony Messenger?
Symfony Messenger is a component for message-bus-based communication, commands, queries and events are forwarded to handlers, synchronously or asynchronously over configurable transports.
2Command vs. query vs. event?
Commands: intent, one handler. Queries: read data, return value. Events: what happened, zero to n handlers. This separation enables CQRS and a clear system architecture.
3Which transport to choose?
RabbitMQ for high load, Redis Streams for moderate load, Doctrine for simple setups without external infrastructure. Switching transports only requires configuration, the handler code stays identical.
4How does retry work?
Exponential backoff: the wait time doubles after each failure. Maximum attempts are configurable. After the last failed attempt, the message lands in the failure transport.
5What is the failure transport?
Dead letter queue for messages that ultimately failed. messenger:failed:show lists them, messenger:failed:retry redelivers them manually. Essential for production monitoring.
6How do I test Messenger code?
InMemoryTransport collects messages without a real queue. In Symfony tests: $this->transport('async')->queue() checks whether the right messages were dispatched.
7What are domain events?
State changes in the domain, collected in the entity, dispatched via the event bus after a successful Doctrine flush. Guarantees consistency between persistence and reaction.
8Multiple handlers for one event?
Yes, when the bus is configured with allow_no_handlers: true. For command buses only one handler is allowed, a second one triggers an exception.
9Starting worker processes?
php bin/console messenger:consume transport_name. Supervisor or systemd for production. --limit=500 stops after 500 messages to prevent memory leaks.
10Stamp vs. envelope?
Envelope is the container holding the message and its stamps. Stamps are metadata objects: DelayStamp, RedeliveryStamp, TransportMessageIdStamp. Middlewares read and write stamps to pass context between processing stages.