# Symfony Messenger: Queues, Workers and Async Architecture in 2026 > Deep dive into Symfony Messenger covering message bus architecture, transport configuration, worker management, deduplication middleware, retry strategies, and the streaming AMQP transport in Symfony 7.3+. - Published: 2026-05-20 - Updated: 2026-05-20 - Author: SharpSkill - Tags: symfony, messenger, php, async, message queue, workers - Reading time: 12 min --- Symfony Messenger handles asynchronous workloads in PHP through a structured message bus, dedicated transports, and supervised workers. With Symfony 7.3 and 8.0 bringing deduplication middleware, streaming AMQP, and Doctrine keepalive, the component now rivals dedicated queue systems like Laravel Horizon or Sidekiq in feature completeness. > **Symfony 7.3+ Messenger Highlights** > > Symfony 7.3 adds DeduplicateMiddleware for automatic message dedup, Doctrine transport keepalive to prevent redelivery of long-running tasks, and the #[AsMessage] attribute for declarative transport routing. ## Symfony Messenger Architecture: Bus, Transport, and Worker The Messenger component separates three concerns: dispatching (the bus), delivery (the transport), and processing (the worker). A message is a plain PHP object. A handler is an invokable class. The bus connects both, routing through middleware and optionally serializing the message to a transport for async processing. ```php // src/Message/InvoiceGenerated.php namespace App\Message; final readonly class InvoiceGenerated { public function __construct( public int $orderId, public string $customerEmail, ) {} } ``` This message carries only scalar data, not Doctrine entities. Passing IDs instead of objects avoids serialization issues and keeps messages lightweight. The handler fetches fresh data from the database at processing time. ```php // src/MessageHandler/InvoiceGeneratedHandler.php namespace App\MessageHandler; use App\Message\InvoiceGenerated; use App\Service\InvoiceService; use App\Service\MailerService; use Symfony\Component\Messenger\Attribute\AsMessageHandler; #[AsMessageHandler] final readonly class InvoiceGeneratedHandler { public function __construct( private InvoiceService $invoiceService, private MailerService $mailerService, ) {} public function __invoke(InvoiceGenerated $message): void { $pdf = $this->invoiceService->generatePdf($message->orderId); $this->mailerService->sendInvoice( $message->customerEmail, $pdf, ); } } ``` Dispatching the message from a controller or service is a single line: ```php // src/Controller/OrderController.php $this->bus->dispatch(new InvoiceGenerated( orderId: $order->getId(), customerEmail: $order->getCustomer()->getEmail(), )); ``` The bus decides synchronous vs. asynchronous execution based on the transport routing configuration. ## Transport Configuration and Queue Backends Messenger supports Doctrine DBAL, Redis, Amazon SQS, Beanstalkd, AMQP (RabbitMQ), and the new [streaming AMQP transport](https://symfony.com/blog/introducing-a-streaming-amqp-transport-for-symfony-messenger) introduced in 2025. Each transport is a DSN string. ```yaml # config/packages/messenger.yaml framework: messenger: failure_transport: failed transports: async_priority_high: dsn: '%env(MESSENGER_TRANSPORT_DSN)%' options: queue_name: high retry_strategy: max_retries: 3 delay: 1000 multiplier: 3 max_delay: 60000 async_priority_low: dsn: '%env(MESSENGER_TRANSPORT_DSN)%' options: queue_name: low retry_strategy: max_retries: 5 delay: 5000 multiplier: 2 failed: dsn: 'doctrine://default?queue_name=failed' routing: 'App\Message\InvoiceGenerated': async_priority_high 'App\Message\CleanupTempFiles': async_priority_low ``` Splitting transports by priority ensures time-sensitive tasks (invoice generation) are processed before housekeeping tasks (temp file cleanup). Each transport gets its own retry strategy, tuned to the criticality and idempotency of its messages. > **Doctrine vs. Redis vs. AMQP** > > Doctrine transport requires no extra infrastructure but adds database load. Redis offers sub-millisecond latency. AMQP (RabbitMQ) provides advanced routing, dead-letter exchanges, and the new streaming transport for high-throughput scenarios. Choose based on existing infrastructure and throughput requirements. ## Worker Management with Supervisor Workers consume messages from transports. In production, the `messenger:consume` command runs under a process manager like [Supervisor](http://supervisord.org/) or systemd. ```bash # Consume high-priority messages first, then low-priority php bin/console messenger:consume async_priority_high async_priority_low \ --memory-limit=128M \ --time-limit=3600 \ --limit=500 ``` The three limit flags prevent memory leaks and ensure workers restart periodically. Supervisor restarts the process after each exit. ```ini ; /etc/supervisor/conf.d/messenger-worker.conf [program:messenger-consume] command=php /var/www/app/bin/console messenger:consume async_priority_high async_priority_low --memory-limit=128M --time-limit=3600 user=www-data numprocs=2 process_name=%(program_name)s_%(process_num)02d autostart=true autorestart=true startsecs=0 stopwaitsecs=30 stdout_logfile=/var/log/messenger-worker.log stderr_logfile=/var/log/messenger-worker-error.log ``` Running `numprocs=2` spawns two parallel workers, doubling throughput. Adjust based on server CPU cores and message processing time. ## Middleware Pipeline and CQRS with Multiple Buses Middleware wraps every message dispatch, adding cross-cutting concerns. The built-in middleware stack handles validation, Doctrine transactions, and routing. ```yaml # config/packages/messenger.yaml framework: messenger: default_bus: command.bus buses: command.bus: middleware: - validation - doctrine_transaction query.bus: middleware: - validation event.bus: default_middleware: allow_no_handlers: true middleware: - validation ``` This configuration implements CQRS (Command Query Responsibility Segregation). Commands mutate state inside a Doctrine transaction. Queries are read-only. Events allow zero handlers, enabling a pub/sub pattern where listeners can be added independently. ## Deduplication Middleware in Symfony 7.3 Duplicate messages waste resources and can cause side effects like double-charging a customer. Symfony 7.3 introduces [DeduplicateMiddleware](https://symfony.com/blog/new-in-symfony-7-3-messenger-improvements) to automatically skip identical messages already queued. ```php // src/Message/SendWelcomeEmail.php namespace App\Message; use Symfony\Component\Messenger\Stamp\DeduplicateStamp; final readonly class SendWelcomeEmail { public function __construct( public int $userId, ) {} } ``` ```php // Dispatching with deduplication use Symfony\Component\Messenger\Stamp\DeduplicateStamp; $this->bus->dispatch( new SendWelcomeEmail(userId: 42), [new DeduplicateStamp(id: 'welcome-email-42')], ); ``` The `DeduplicateStamp` takes a lock resource identifier. If a message with the same ID is already pending, the new dispatch is silently dropped. This requires the [Lock component](https://symfony.com/doc/current/lock.html) with a serializable store (Redis, Memcached, or database). ## Retry Strategies and Failure Transport When a handler throws an exception, Messenger retries the message according to the transport's retry strategy. After exhausting retries, the message moves to the failure transport. ```php // src/MessageHandler/PaymentHandler.php namespace App\MessageHandler; use App\Message\ProcessPayment; use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Messenger\Exception\RecoverableMessageHandlingException; #[AsMessageHandler] final class PaymentHandler { public function __invoke(ProcessPayment $message): void { try { $this->gateway->charge($message->amount, $message->token); } catch (GatewayTimeoutException $e) { // Recoverable: retry with backoff throw new RecoverableMessageHandlingException( 'Payment gateway timeout, retrying', previous: $e, ); } catch (InvalidCardException $e) { // Unrecoverable: send to failure transport immediately throw new UnrecoverableMessageHandlingException( 'Invalid card, no retry', previous: $e, ); } } } ``` `RecoverableMessageHandlingException` triggers the retry strategy. `UnrecoverableMessageHandlingException` bypasses retries entirely and sends the message straight to the failure transport. This distinction prevents wasting retries on permanently broken messages. ```bash # Inspect and manage failed messages php bin/console messenger:failed:show php bin/console messenger:failed:show 20 --transport=failed # Retry specific messages php bin/console messenger:failed:retry 20 30 # Filter and remove by class (Symfony 7.3+) php bin/console messenger:failed:remove --class-filter="App\Message\CleanupTempFiles" ``` > **Idempotent Handlers** > > Retries mean a handler may execute multiple times for the same message. Design every handler to be idempotent: check if the work was already done before repeating it. Use database unique constraints or check flags to prevent double processing. ## Doctrine Keepalive and Long-Running Messages Long-running message handlers risk having their messages redelivered when the transport's visibility timeout expires. Symfony 7.2 introduced keepalive for Redis, SQS, and Beanstalkd. Symfony 7.3 [extends this to Doctrine transport](https://symfony.com/blog/new-in-symfony-7-3-messenger-improvements). ```bash # Enable keepalive to prevent redelivery during long processing php bin/console messenger:consume async --keepalive ``` The `--keepalive` flag periodically updates the `delivered_at` timestamp in the Doctrine transport table, signaling that the worker is still actively processing the message. Without this flag, a message processing for longer than the transport timeout (default 5 minutes) gets picked up by another worker, causing duplicate processing. ## The #[AsMessage] Attribute for Declarative Routing Symfony 7.2 introduced the `#[AsMessage]` attribute, moving transport routing from YAML to the message class itself. ```php // src/Message/GenerateReport.php namespace App\Message; use Symfony\Component\Messenger\Attribute\AsMessage; #[AsMessage(transport: 'async_priority_low')] final readonly class GenerateReport { public function __construct( public int $reportId, public string $format = 'pdf', ) {} } ``` This eliminates the `routing` section in `messenger.yaml` for this message. The transport is declared at the source, making the codebase self-documenting. Both approaches (YAML routing and attribute) coexist, with the attribute taking precedence. ## Streaming AMQP Transport for High-Throughput Queues The traditional AMQP transport uses polling (`get()`) to fetch messages, generating unnecessary load on RabbitMQ. The [streaming AMQP transport](https://symfony.com/blog/introducing-a-streaming-amqp-transport-for-symfony-messenger) released in 2025 switches to a push model (`consume()`), reducing latency and resource usage. ```yaml # config/packages/messenger.yaml framework: messenger: transports: streaming: dsn: 'amqp-lib://guest:guest@localhost:5672/%2f/messages' options: exchange: name: app_events type: topic queues: order_events: binding_keys: ['order.*'] ``` Key differences from the default AMQP transport: no C extension required (uses [php-amqplib](https://github.com/php-amqplib/php-amqplib)), streaming delivery over long-lived TCP connections, and native support for topic exchanges with binding key routing. This transport handles thousands of messages per second with minimal CPU overhead. ## Conclusion - Pass scalar IDs in messages, not Doctrine entities; fetch fresh data in the handler to avoid serialization issues and stale state - Split transports by priority and criticality; each transport gets its own retry strategy and dedicated worker pool - Use `RecoverableMessageHandlingException` and `UnrecoverableMessageHandlingException` to control retry behavior explicitly - Enable `--keepalive` on Doctrine transport workers to prevent redelivery of long-running messages - Apply `DeduplicateStamp` on messages where duplicate processing causes side effects (payments, emails, notifications) - Implement CQRS with multiple buses: command bus for mutations with Doctrine transactions, query bus for reads, event bus for pub/sub - Use Supervisor or systemd in production with `--memory-limit`, `--time-limit`, and `--limit` flags for worker lifecycle management - Consider the streaming AMQP transport for high-throughput scenarios requiring sub-millisecond latency --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/en/blog/symfony/symfony-messenger-queues-workers-async-architecture