SEO Metadata
SEO Title Options
- Symfony Messenger: Async Messaging, Retry Logic & Dead
- Symfony Messenger: Async Messaging, Retry: Practical 2026
- Symfony Playbook: Symfony Messenger: Async Messaging
Meta Description Options
- Learn Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues with a practical Symfony framework, expert mistakes, implementation steps.
- Explores Symfony Messenger transports, middleware, retry stamps, failure transport, and monitoring with built-in CLI commands.
URL Slug
symfony-messenger-async-messaging-retry-logic-dead-letter-queues
Focus Keyword
Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues
Additional LSI Keywords
- Symfony
- Messenger
- Queues
- Async
- PHP
- Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues
- production checklist
- implementation guide
- best practices
- architecture decisions
- testing strategy
- performance impact
Table of Contents
- Article overview
- What Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues means
- Why it matters now
- Implementation framework
- Practical comparison
- Expert workflow
- Common mistakes
- Media and link plan
- Original technical deep dive
- FAQ
- Structured data
- Conclusion
Article overview
Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues is the kind of topic that looks simple until it reaches production. Teams usually discover the real cost late: unclear boundaries, weak defaults, hidden maintenance work, and decisions that seemed harmless when the codebase was small.
The problem gets worse when the article, tutorial, or implementation guide only explains the happy path. This guide closes that gap with a practical framework, a comparison table, common mistakes, and a deep technical section you can use while planning real work.
Keep reading for the non-obvious part: the safest implementation is rarely the most impressive-looking one. It is the one your team can debug, test, document, and evolve without turning every future change into archaeology.
Key Takeaways
- Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues should be evaluated as a production decision, not only as a syntax or tooling choice.
- The best implementation keeps responsibilities visible, with clear ownership, tests, documentation, and rollback paths.
- Search visibility improves when practical depth, structured answers, and expert examples live on the same page.
[IMAGE: A mobile-first technical article layout showing the main concept, decision table, implementation checklist, and FAQ blocks. Alt: Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues expert guide for Symfony]
What Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues means
Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues means applying symfony knowledge to a concrete engineering decision, then turning that decision into reliable code, documentation, and operational behavior. In practice, it combines the topic's core concepts with trade-off analysis, implementation boundaries, testing strategy, and maintenance discipline.
This is the definition worth optimizing for featured snippets because it avoids hype. It tells the reader what the topic does and what a professional implementation must include.
Why it matters now
The technical web is more crowded than it was a few years ago. Thin tutorials can still get indexed, but they rarely earn trust from senior developers, buyers, AI answer systems, or teams that need production guidance.
For symfony topics, the strongest content now has three layers:
- a clear answer for fast scanning
- a practical framework for implementation
- expert context that explains what breaks later
That same structure helps search engines understand the page. It also helps readers decide whether the advice fits their project.
Implementation framework
Use this framework before adopting the approach described in this article.
- Define the user problem and the production risk.
- Identify the smallest reliable implementation boundary.
- Keep configuration, secrets, and environment-specific behavior outside the article's core logic.
- Add tests for the behavior that would hurt if it regressed.
- Document the trade-off, not only the final code.
- Measure the result with logs, metrics, or user-facing outcomes.
- Revisit the decision after real usage exposes edge cases.
The sequence is deliberately conservative. It keeps the work grounded in outcomes instead of novelty.
[IMAGE: A seven-step implementation framework with discovery, boundary design, configuration, tests, documentation, measurement, and iteration. Alt: Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues implementation framework]
Practical comparison
| Decision area | Strong approach | Weak approach | Why it matters |
|---|---|---|---|
| Scope | Solve one clear problem | Mix unrelated concerns | Focus improves testing and search intent |
| Architecture | Put logic in explicit classes or documented boundaries | Hide behavior in templates or incidental callbacks | Future changes stay easier to review |
| Data flow | Pass prepared data into the view or endpoint | Query or compute in presentation code | Reduces regressions and performance surprises |
| Testing | Cover the risky behavior directly | Test only the happy path | Catches production failures earlier |
| Documentation | Explain trade-offs and limits | Repeat generic definitions | Builds E-E-A-T and reader trust |
| Operations | Track logs, metrics, and rollback steps | Ship without measurement | Makes the decision reversible |
This table is intentionally practical. It gives a reviewer something to check before the implementation becomes expensive to change.
Expert workflow
Expert tip: "Treat Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues as a system boundary. If the next developer cannot find where the decision lives, how it is tested, and when it should be avoided, the implementation is not finished."
A useful workflow is simple:
- Start with the smallest working example.
- Add the constraints that exist in your real project.
- Remove anything that only demonstrates cleverness.
- Write down the failure modes.
- Add links to related decisions so future readers can navigate the topic cluster.
That last point matters for both humans and search systems. A single article can answer a question; a cluster proves authority.
Common mistakes
Mistake 1: Copying a pattern without its context
A pattern that works in a small demo can fail in a real application. The missing context is usually data volume, team experience, deployment process, security requirements, or observability.
Before copying the pattern, ask what assumption made it safe in the original example.
Mistake 2: Putting business logic in the wrong layer
This is the fastest way to make future debugging expensive. In Laravel, PHP, and server-rendered websites, presentation should receive prepared data, not discover rules on its own.
Keep decision logic in models, actions, services, policies, requests, jobs, or documented helpers where it can be tested directly.
Mistake 3: Optimizing for novelty instead of maintainability
Newer tools and language features can be valuable. They can also hide simple behavior behind unfamiliar syntax.
Use the option that makes the next production incident easier to understand.
Mistake 4: Publishing without a measurement plan
If the article describes a performance, SEO, security, or architecture improvement, define how success will be checked. Logs, tests, crawl diagnostics, analytics, and user behavior are all stronger than assumptions.
[IMAGE: A common-mistakes board with context loss, wrong layer, novelty bias, and missing measurement highlighted. Alt: Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues common mistakes]
Media and link plan
Image placeholders
- [IMAGE: A concept diagram for Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues with input, decision boundary, implementation, tests, and production feedback. Alt: Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues concept diagram]
- [IMAGE: A mobile screenshot-style checklist for Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues. Alt: Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues mobile checklist]
- [IMAGE: A comparison table visualization for strong versus weak implementation choices. Alt: Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues comparison table]
Video placeholder
[VIDEO: Insert a 5-8 minute YouTube walkthrough that demonstrates the main decision, the implementation boundary, the test strategy, and the production caveats for Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues.]
Trustworthy outbound links
- PHP manual - use this as the trust reference for language-level reference.
- Symfony documentation - use this as the trust reference for component and framework reference.
Internal linking opportunities
- Internal guide: Symfony UX Components: Turbo, Stimulus & Live - use this when readers need a related Symfony follow-up.
- Internal guide: Symfony 7 Migration Guide: What Changed and - use this when readers need a related Symfony follow-up.
Original Technical Deep Dive
The short version
Symfony Messenger is not just a queue wrapper. It gives you:
- Message classes.
- Handler discovery.
- Buses and middleware.
- Sync and async routing.
- Transports for queues and brokers.
- Retry policy per transport.
- Failure transports for dead-letter handling.
- Worker commands for consuming, stopping, inspecting, retrying, and removing messages.
The production rule is simple: every async message must be safe to run more than once, every transport needs a retry policy, and every important transport needs a failure transport. If you skip the failure transport, exhausted messages can be discarded after retries.
Mental model
Messenger has four moving parts:
| Part | Job |
|---|---|
| Message | A small, serializable command or event object |
| Handler | The service that performs work for that message |
| Bus | The middleware pipeline that dispatches the message |
| Transport | The queue, broker, or sync mechanism that stores messages before handling |
If a message is not routed to a transport, Messenger handles it synchronously. If it is routed to an async transport, it is serialized, sent to the transport, and handled later by a worker.
That distinction matters. Dispatching a message does not always mean "queued." Routing decides.
Install Messenger
Install the component:
composer require symfony/messenger
Choose a transport package based on where messages should live:
# Database table transport
composer require symfony/doctrine-messenger
# RabbitMQ and other AMQP brokers
composer require symfony/amqp-messenger
# Redis Streams transport
composer require symfony/redis-messenger
For development and smaller systems, Doctrine transport is convenient. For high-volume queues or cross-service messaging, use a real broker such as RabbitMQ, SQS, Redis Streams, or another transport that fits the system.
Define messages as data
Messages should be small and serializable. Pass identifiers, not Doctrine entities.
declare(strict_types=1);
namespace App\Message;
final readonly class SendOrderReceipt
{
public function __construct(
public int $orderId,
public string $idempotencyKey,
) {}
}
Bad message shape:
final readonly class SendOrderReceipt
{
public function __construct(
public Order $order,
) {}
}
The bad version serializes a live ORM object. In a worker, that object may be stale, detached from the EntityManager, or impossible to deserialize after a deploy. Store the id and load fresh state in the handler.
Write one handler per job
declare(strict_types=1);
namespace App\MessageHandler;
use App\Message\SendOrderReceipt;
use App\Repository\OrderRepository;
use App\Service\ReceiptMailer;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final readonly class SendOrderReceiptHandler
{
public function __construct(
private OrderRepository $orders,
private ReceiptMailer $mailer,
) {}
public function __invoke(SendOrderReceipt $message): void
{
$order = $this->orders->get($message->orderId);
if ($order->receiptWasSent()) {
return;
}
$this->mailer->sendReceipt($order, $message->idempotencyKey);
$order->markReceiptSent($message->idempotencyKey);
$this->orders->save($order);
}
}
The handler is idempotent:
- It checks whether the receipt was already sent.
- It uses a stable idempotency key.
- It persists completion.
Messenger gives at least once delivery semantics in normal production conditions. A worker can process a message and crash before acknowledging it. The transport can redeliver it. Your handler must survive that.
Dispatch messages from application services
declare(strict_types=1);
namespace App\Service;
use App\Message\SendOrderReceipt;
use Symfony\Component\Messenger\MessageBusInterface;
final readonly class CheckoutService
{
public function __construct(
private MessageBusInterface $bus,
) {}
public function completeCheckout(int $orderId): void
{
// Persist the checkout result first.
$this->bus->dispatch(new SendOrderReceipt(
orderId: $orderId,
idempotencyKey: sprintf('order-receipt-%d', $orderId),
));
}
}
Dispatching inside controllers works, but a service boundary is cleaner. Controllers should validate HTTP input and call application code. They should not decide queue topology.
[IMAGE: Supporting visual 1 for Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues, showing Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues decisions, examples, and Symfony, Messenger, Queues. Alt: Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues symfony-messenger-async-messaging-retry-logic-dead-letter-queues visual 1]
[IMAGE: Supporting visual 1 for Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues, showing Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues decisions, examples, and Symfony, Messenger, Queues. Alt: Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues symfony-messenger-async-messaging-retry-logic-dead-letter-queues visual 1]
Configure transports and routing
Start explicit:
# config/packages/messenger.yaml
framework:
messenger:
failure_transport: failed_default
transports:
async_high:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 5
delay: 1000
multiplier: 2
max_delay: 60000
jitter: 0.2
failure_transport: failed_high
async_default:
dsn: 'doctrine://default?queue_name=async_default'
retry_strategy:
max_retries: 3
delay: 5000
multiplier: 2
max_delay: 300000
jitter: 0.1
async_low:
dsn: 'doctrine://default?queue_name=async_low'
retry_strategy:
max_retries: 2
delay: 60000
multiplier: 2
max_delay: 900000
jitter: 0.2
failed_default: 'doctrine://default?queue_name=failed_default'
failed_high: 'doctrine://default?queue_name=failed_high'
routing:
App\Message\SendOrderReceipt: async_high
App\Message\SyncSearchIndex: async_default
App\Message\BuildReportExport: async_low
This gives you:
- High-priority messages with their own failure transport.
- Normal async work with the global failure transport.
- Low-priority work separated from user-visible work.
- Retry timing tuned by workload instead of one global default.
Avoid routing everything to one transport called async. A report export should not delay a payment receipt.
Understand retry strategy fields
Retry strategy is per transport:
| Field | Meaning |
|---|---|
max_retries | How many retries happen before the message goes to failure transport or is discarded |
delay | Delay before the first retry, in milliseconds |
multiplier | Factor applied to later retry delays |
max_delay | Upper bound for retry delay |
jitter | Randomness added to avoid many messages retrying at the same moment |
service | Custom retry strategy service implementing RetryStrategyInterface |
Use short retry windows for local database hiccups. Use longer retry windows for third-party APIs, rate limits, mail providers, payment webhooks, and anything with predictable external downtime.
Bad:
retry_strategy:
max_retries: 99
delay: 100
That can turn one outage into a retry storm.
Better:
retry_strategy:
max_retries: 5
delay: 10000
multiplier: 3
max_delay: 900000
jitter: 0.2
That retries after 10 seconds, then backs off and spreads retries.
Use exception type to express retry intent
Some failures are permanent. Do not waste retries.
declare(strict_types=1);
namespace App\MessageHandler;
use App\Message\SendOrderReceipt;
use App\Service\ReceiptMailer;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
final readonly class SendOrderReceiptHandler
{
public function __construct(
private ReceiptMailer $mailer,
) {}
public function __invoke(SendOrderReceipt $message): void
{
if ($message->orderId <= 0) {
throw new UnrecoverableMessageHandlingException('Invalid order id.');
}
$this->mailer->send($message->orderId, $message->idempotencyKey);
}
}
Some failures are temporary and must retry even if the configured retry count is exhausted:
declare(strict_types=1);
use Symfony\Component\Messenger\Exception\RecoverableMessageHandlingException;
if ($providerResponse->statusCode === 429) {
throw new RecoverableMessageHandlingException(
message: 'Provider rate limit reached.',
retryDelay: 120000,
);
}
Use infinite recoverable retries carefully. If the external system can reject a message forever, you still need an alert and an operational stop condition outside the handler.
Failure transport is your dead-letter queue
Symfony calls this a failure_transport. Many teams call it a dead-letter queue.
Configure one globally:
framework:
messenger:
failure_transport: failed_default
transports:
async_default: '%env(MESSENGER_TRANSPORT_DSN)%'
failed_default: 'doctrine://default?queue_name=failed_default'
Or override it for a specific transport:
framework:
messenger:
failure_transport: failed_default
transports:
async_high:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
failure_transport: failed_high
failed_default: 'doctrine://default?queue_name=failed_default'
failed_high: 'doctrine://default?queue_name=failed_high'
Why separate failure transports help:
- Failed payment messages can alert a different team than failed report exports.
- High-priority failures can be retried first.
- You can inspect noisy low-priority failures without hiding critical incidents.
- The retry command can target one failure transport.
If no global or per-transport failure transport exists, exhausted messages can be discarded after retries. That is rarely acceptable for business work.
[IMAGE: Supporting visual 2 for Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues, showing Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues decisions, examples, and Symfony, Messenger, Queues. Alt: Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues symfony-messenger-async-messaging-retry-logic-dead-letter-queues visual 2]
Inspect failed messages
Common commands:
# show the default failure transport
php bin/console messenger:failed:show
# show failure counts by message class
php bin/console messenger:failed:show --stats
# inspect one failed message with details
php bin/console messenger:failed:show 20 -vv
# retry failed messages interactively
php bin/console messenger:failed:retry -vv
# retry selected ids without prompting
php bin/console messenger:failed:retry 20 30 --force
# remove a known-bad failed message
php bin/console messenger:failed:remove 20
# target a named failure transport
php bin/console messenger:failed:show --transport=failed_high
php bin/console messenger:failed:retry 20 --transport=failed_high --force
Operational rule: do not retry the dead-letter queue blindly. First identify whether the failure was:
- A transient infrastructure outage.
- A bug that has now been deployed.
- A bad message payload.
- A missing database row.
- A permission or tenant mismatch.
- A provider rejection that will never succeed.
[IMAGE: Supporting visual 2 for Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues, showing Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues decisions, examples, and Symfony, Messenger, Queues. Alt: Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues symfony-messenger-async-messaging-retry-logic-dead-letter-queues visual 2]
Retry only after the reason is understood.
Run workers deliberately
Consume one transport:
php bin/console messenger:consume async_default -vv
Consume multiple transports in priority order:
php bin/console messenger:consume async_high async_default async_low -vv
The worker checks async_high first. If high-priority work is present, it handles that before falling through to lower-priority transports.
Use limits so long-running PHP workers recycle:
php bin/console messenger:consume async_default \
--time-limit=3600 \
--memory-limit=256M \
--limit=500 \
--failure-limit=10
Why:
- Doctrine connections can go stale.
- Services can accumulate state.
- Memory can grow over time.
- Deploys need workers to pick up new code.
Workers should run under a process manager, not a terminal session.
Supervisor example
[program:symfony-messenger-high]
command=php /var/www/app/bin/console messenger:consume async_high --time-limit=3600 --memory-limit=256M -vv
user=www-data
numprocs=2
startsecs=0
autostart=true
autorestart=true
startretries=20
process_name=%(program_name)s_%(process_num)02d
redirect_stderr=true
stdout_logfile=/var/log/supervisor/symfony-messenger-high.log
For Redis transport, give each worker a unique consumer name:
environment=MESSENGER_CONSUMER_NAME=%(program_name)s_%(process_num)02d
Then use that environment value in the Redis DSN or transport options.
Restart workers on deploy
After new code is deployed and cache is warm:
php bin/console messenger:stop-workers
The process manager should restart workers. This prevents old workers from processing new messages with old code.
On multiple hosts, the cache used by messenger:stop-workers must be shared. If each host has its own local cache, the stop signal may not reach every worker.
Use middleware for bus behavior
Middleware belongs on the bus, not in every handler.
Example command bus:
framework:
messenger:
default_bus: command.bus
buses:
command.bus:
middleware:
- validation
- doctrine_ping_connection
- doctrine_close_connection
- doctrine_open_transaction_logger
- doctrine_transaction
Useful middleware choices:
| Middleware | Use |
|---|---|
validation | Validate message DTOs before handling |
doctrine_ping_connection | Reconnect stale database connections in long-running workers |
doctrine_close_connection | Avoid holding database connections forever |
doctrine_open_transaction_logger | Catch leaked transactions |
doctrine_transaction | Wrap handler work in a transaction |
Do not add domain decisions to middleware. Middleware should enforce cross-cutting behavior: validation, transactions, logging, tracing, tenant context, and rate limiting.
Use stamps for routing and metadata
Messenger wraps messages in an Envelope. Stamps add transport and handling metadata.
[IMAGE: Supporting visual 3 for Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues, showing Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues decisions, examples, and Symfony, Messenger, Queues. Alt: Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues symfony-messenger-async-messaging-retry-logic-dead-letter-queues visual 3]
Delay a dispatch:
declare(strict_types=1);
use App\Message\SendOrderReceipt;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\DelayStamp;
final readonly class ReceiptScheduler
{
public function __construct(
private MessageBusInterface $bus,
) {}
public function sendLater(int $orderId): void
{
$this->bus->dispatch(
new SendOrderReceipt($orderId, sprintf('order-receipt-%d', $orderId)),
[new DelayStamp(300000)],
);
}
}
Override transport at runtime:
use Symfony\Component\Messenger\Stamp\TransportNamesStamp;
$bus->dispatch(
new BuildReportExport($reportId),
[new TransportNamesStamp(['async_low'])],
);
Use this sparingly. Static routing in messenger.yaml is easier to audit. Runtime routing is useful when the target transport depends on tenant plan, import size, or an operational feature flag.
Inspect retry metadata
Retry attempts are represented on the envelope with retry-related stamps. For observability, log retry events rather than branching business behavior on retry count.
declare(strict_types=1);
namespace App\Messenger;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Messenger\Event\WorkerMessageRetriedEvent;
use Symfony\Component\Messenger\Stamp\RedeliveryStamp;
#[AsEventListener]
final readonly class MessengerRetryLogger
{
public function __construct(
private LoggerInterface $logger,
) {}
public function __invoke(WorkerMessageRetriedEvent $event): void
{
$envelope = $event->getEnvelope();
$message = $envelope->getMessage();
$this->logger->warning('Messenger message scheduled for retry.', [
'message_class' => $message::class,
'receiver' => $event->getReceiverName(),
'retry_count' => RedeliveryStamp::getRetryCountFromEnvelope($envelope),
]);
}
}
This gives you structured logs every time Messenger schedules a retry.
Do not write handlers like this:
if ($retryCount > 2) {
// Use a different business rule.
}
The retry count is operational metadata. Business rules should come from the message and domain state, not from how many times infrastructure tried to run it.
[IMAGE: Supporting visual 3 for Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues, showing Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues decisions, examples, and Symfony, Messenger, Queues. Alt: Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues symfony-messenger-async-messaging-retry-logic-dead-letter-queues visual 3]
Observe queue health with commands
Start with:
# list messages and handlers per bus
php bin/console debug:messenger
# count queued messages for supported transports
php bin/console messenger:stats
# JSON output for scripts and dashboards
php bin/console messenger:stats --format=json
# failed-message summary
php bin/console messenger:failed:show --stats
messenger:stats depends on transport support for message counting. If a transport cannot count messages, use broker-native monitoring too.
For production, track at least:
| Signal | Why |
|---|---|
| Queue depth per transport | Detect backlog |
| Oldest message age | Better than count for user impact |
| Worker count per transport | Confirm capacity |
| Handler duration | Find slow jobs |
| Retry count by message class | Detect unstable dependencies |
| Failed message count | Dead-letter backlog |
| Worker restarts and exits | Catch crashes and memory limits |
Symfony commands are useful. They are not a complete observability stack. Use broker metrics, logs, traces, and alerts around them.
Design retry-safe handlers
A handler is retry-safe when the second run cannot corrupt data.
Checklist:
- Message contains stable identifiers.
- Handler loads current state.
- External calls use idempotency keys where possible.
- Database writes have unique constraints for idempotent effects.
- Handler can stop early if work is already complete.
- Side effects happen after required state checks.
- Non-retryable data errors throw
UnrecoverableMessageHandlingException. - Temporary provider failures throw normal exceptions or
RecoverableMessageHandlingException.
Example table:
CREATE TABLE sent_receipts (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
order_id BIGINT NOT NULL,
idempotency_key VARCHAR(120) NOT NULL,
sent_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
CONSTRAINT uniq_sent_receipts_key UNIQUE (idempotency_key)
);
The application check is useful. The unique constraint is the actual protection against concurrent redelivery.
Choose transport by workload
| Transport | Good fit | Watch out |
|---|---|---|
sync:// | Explicit synchronous handling in tests or local code paths | Not async |
| Doctrine | Simple deployments, low to moderate volume, local development | Database polling and table growth |
| AMQP | RabbitMQ, routing keys, priority-style topology, broker operations | Broker setup and AMQP extension |
| Redis | Redis Streams, quick operational setup, stream consumers | Unique consumer names and Redis memory |
| SQS | AWS queue workloads | Cloud-specific visibility timeout and IAM |
[IMAGE: Supporting visual 4 for Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues, showing Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues decisions, examples, and Symfony, Messenger, Queues. Alt: Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues symfony-messenger-async-messaging-retry-logic-dead-letter-queues visual 4]
Do not choose a transport because it is easy to install. Choose it because its failure behavior, visibility timeout, monitoring, and operational model match the job.
Production configuration pattern
Use environment-specific DSNs:
###> symfony/messenger ###
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=false
MESSENGER_HIGH_TRANSPORT_DSN=amqp://guest:guest@rabbitmq:5672/%2f/high
###< symfony/messenger ###
And explicit routing:
framework:
messenger:
failure_transport: failed_default
transports:
async_high:
dsn: '%env(MESSENGER_HIGH_TRANSPORT_DSN)%'
failure_transport: failed_high
retry_strategy:
max_retries: 5
delay: 1000
multiplier: 2
max_delay: 60000
jitter: 0.2
async_default:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 5000
multiplier: 2
max_delay: 300000
jitter: 0.1
failed_default: 'doctrine://default?queue_name=failed_default&auto_setup=false'
failed_high: 'doctrine://default?queue_name=failed_high&auto_setup=false'
routing:
App\Message\PaymentCaptured: async_high
App\Message\SendOrderReceipt: async_high
App\Message\SyncSearchIndex: async_default
In production, prefer migrations and explicit setup over transport auto-creation. Queue and table creation should be part of deploy infrastructure, not a surprise side effect of the first dispatched message.
Testing Messenger code
Unit test handlers without a transport:
declare(strict_types=1);
use App\Message\SendOrderReceipt;
use App\MessageHandler\SendOrderReceiptHandler;
use PHPUnit\Framework\TestCase;
final class SendOrderReceiptHandlerTest extends TestCase
{
public function testItDoesNotSendDuplicateReceipts(): void
{
$orders = new InMemoryOrderRepository([
OrderStub::receiptAlreadySent(id: 10),
]);
$mailer = new FakeReceiptMailer();
$handler = new SendOrderReceiptHandler($orders, $mailer);
$handler(new SendOrderReceipt(
orderId: 10,
idempotencyKey: 'order-receipt-10',
));
self::assertSame(0, $mailer->sentCount());
}
}
Then add integration tests for routing and transport behavior:
php bin/console debug:messenger
php bin/console messenger:consume async_default --limit=1 -vv
In Symfony test environments, it is often useful to route messages to sync:// or use a test transport for assertions. Keep at least one integration path that proves serialization works, because unserializable messages fail only when they leave the request.
[IMAGE: Supporting visual 4 for Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues, showing Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues decisions, examples, and Symfony, Messenger, Queues. Alt: Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues symfony-messenger-async-messaging-retry-logic-dead-letter-queues visual 4]
Common mistakes
Mistake: putting Doctrine entities in messages.
Fix: put scalar identifiers in messages and load fresh state in the handler.
Mistake: treating dispatch as "fire and forget."
Fix: failed messages still need owners, alerts, and a retry/removal process.
Mistake: retrying non-idempotent work.
Fix: add idempotency keys, completion records, and database constraints.
Mistake: using one transport for every workload.
Fix: split high-priority, default, low-priority, and failure transports.
Mistake: running workers forever.
Fix: use --time-limit, --memory-limit, --limit, messenger:stop-workers, and a process manager.
Mistake: deleting failed messages to make the dashboard clean.
Fix: document why each failure is retried, fixed, ignored, or removed.
Production checklist
Before shipping async Messenger:
- Every async message has a handler visible in
debug:messenger. - Routing is explicit.
- No message contains Doctrine entities, file handles, closures, or resources.
- Each transport has a retry strategy.
- Important transports have failure transports.
- Failed-message commands are documented for the team.
- Workers run under Supervisor, systemd, containers, or platform process management.
- Worker restart on deploy uses
messenger:stop-workers. - Worker limits are set.
- Retry events are logged or counted.
- Queue depth or oldest-message-age alerts exist.
- Handlers are idempotent.
- External API calls use timeouts.
- Non-retryable errors throw
UnrecoverableMessageHandlingException. - Rate-limit or temporary failures back off.
- Failure queues are reviewed regularly.
[IMAGE: Supporting visual 5 for Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues, showing Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues decisions, examples, and Symfony, Messenger, Queues. Alt: Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues symfony-messenger-async-messaging-retry-logic-dead-letter-queues visual 5]
Messenger is productive because it makes the operational choices explicit. Use that. Do not hide queue behavior behind "dispatch and hope."
FAQ
What is Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues?
Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues is a practical symfony topic that should be evaluated through implementation scope, production risk, testing, documentation, and long-term maintainability.
When should a team use Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues?
Use Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues when it solves a real project constraint, improves clarity, or reduces operational risk. Avoid it when it only adds novelty or hides behavior from future maintainers.
What is the biggest risk with Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues?
The biggest risk is copying a pattern without its context. Production systems need clear boundaries, rollback options, tests, and observability before a technique becomes dependable.
How do you test Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues?
Test the smallest unit that owns the behavior, then add integration coverage for the path users or systems actually rely on. Include failure cases, configuration differences, and regression checks.
How does Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues affect SEO and AI search visibility?
It improves visibility when the article gives a direct answer, expert context, structured headings, internal links, trustworthy references, and FAQ content that matches the visible page.
Conclusion
Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues is worth doing when the implementation improves clarity, reliability, or delivery speed. It is not worth doing when it hides ownership, increases operational risk, or makes the system harder to explain.
Use the framework above as a review checklist. Then connect this topic to the rest of the project documentation so readers can move from concept to implementation without losing context.