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+.

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 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.
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.
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:
$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 introduced in 2025. Each transport is a DSN string.
# 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_lowSplitting 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 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 or systemd.
# 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=500The three limit flags prevent memory leaks and ensure workers restart periodically. Supervisor restarts the process after each exit.
; /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.logRunning 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.
# 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:
- validationThis 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.
Ready to ace your Symfony interviews?
Practice with our interactive simulators, flashcards, and technical tests.
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 to automatically skip identical messages already queued.
namespace App\Message;
use Symfony\Component\Messenger\Stamp\DeduplicateStamp;
final readonly class SendWelcomeEmail
{
public function __construct(
public int $userId,
) {}
}// 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 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.
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.
# 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"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.
# Enable keepalive to prevent redelivery during long processing
php bin/console messenger:consume async --keepaliveThe --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.
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 released in 2025 switches to a push model (consume()), reducing latency and resource usage.
# 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), 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
RecoverableMessageHandlingExceptionandUnrecoverableMessageHandlingExceptionto control retry behavior explicitly - Enable
--keepaliveon Doctrine transport workers to prevent redelivery of long-running messages - Apply
DeduplicateStampon 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--limitflags for worker lifecycle management - Consider the streaming AMQP transport for high-throughput scenarios requiring sub-millisecond latency
Start practicing!
Test your knowledge with our interview simulators and technical tests.
Tags
Share
Related articles

Doctrine ORM: Mastering Relationships in Symfony
Complete guide to Doctrine ORM relationships in Symfony. OneToMany, ManyToMany, loading strategies, and performance optimization with practical examples.

Symfony Interview Questions: Top 25 in 2026
The 25 most asked Symfony interview questions. Architecture, Doctrine ORM, services, security, forms, and testing with detailed answers and code examples.

Symfony 7: API Platform and Best Practices
Complete guide to building professional REST APIs with Symfony 7 and API Platform 4. State Providers, Processors, validation, and serialization explained.