Back to blog

Symfony

Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues

Explores Symfony Messenger transports, middleware, retry stamps, failure transport, and monitoring with built-in CLI commands.

  • Symfony
  • Messenger
  • Queues
  • Async
  • PHP

Reader map

Key points in Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues

Syntax first, runtime behavior second, migration cleanup last.

Read
18 min
Waypoints
8
Track
Symfony
  1. 01
    Start here

    Message classes.

  2. 02
    Waypoint

    Handler discovery.

  3. 03
    Waypoint

    Buses and middleware.

  4. 04
    Waypoint

    Sync and async routing.

  5. 05
    Waypoint

    Transports for queues and brokers.

  6. 06
    Waypoint

    Retry policy per transport.

  7. 07
    Waypoint

    Failure transports for dead-letter handling.

  8. 08
    Migration check

    Worker commands for consuming, stopping, inspecting, retrying, and removing messages.

SEO Metadata

SEO Title Options

  1. Symfony Messenger: Async Messaging, Retry Logic & Dead
  2. Symfony Messenger: Async Messaging, Retry: Practical 2026
  3. Symfony Playbook: Symfony Messenger: Async Messaging

Meta Description Options

  1. Learn Symfony Messenger: Async Messaging, Retry Logic & Dead Letter Queues with a practical Symfony framework, expert mistakes, implementation steps.
  2. 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

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.

  1. Define the user problem and the production risk.
  2. Identify the smallest reliable implementation boundary.
  3. Keep configuration, secrets, and environment-specific behavior outside the article's core logic.
  4. Add tests for the behavior that would hurt if it regressed.
  5. Document the trade-off, not only the final code.
  6. Measure the result with logs, metrics, or user-facing outcomes.
  7. 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 areaStrong approachWeak approachWhy it matters
ScopeSolve one clear problemMix unrelated concernsFocus improves testing and search intent
ArchitecturePut logic in explicit classes or documented boundariesHide behavior in templates or incidental callbacksFuture changes stay easier to review
Data flowPass prepared data into the view or endpointQuery or compute in presentation codeReduces regressions and performance surprises
TestingCover the risky behavior directlyTest only the happy pathCatches production failures earlier
DocumentationExplain trade-offs and limitsRepeat generic definitionsBuilds E-E-A-T and reader trust
OperationsTrack logs, metrics, and rollback stepsShip without measurementMakes 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]

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

  • 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

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:

PartJob
MessageA small, serializable command or event object
HandlerThe service that performs work for that message
BusThe middleware pipeline that dispatches the message
TransportThe 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.

<?php

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

<?php

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

<?php

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:

FieldMeaning
max_retriesHow many retries happen before the message goes to failure transport or is discarded
delayDelay before the first retry, in milliseconds
multiplierFactor applied to later retry delays
max_delayUpper bound for retry delay
jitterRandomness added to avoid many messages retrying at the same moment
serviceCustom 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.

<?php

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:

<?php

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:

MiddlewareUse
validationValidate message DTOs before handling
doctrine_ping_connectionReconnect stale database connections in long-running workers
doctrine_close_connectionAvoid holding database connections forever
doctrine_open_transaction_loggerCatch leaked transactions
doctrine_transactionWrap 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:

<?php

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.

<?php

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:

SignalWhy
Queue depth per transportDetect backlog
Oldest message ageBetter than count for user impact
Worker count per transportConfirm capacity
Handler durationFind slow jobs
Retry count by message classDetect unstable dependencies
Failed message countDead-letter backlog
Worker restarts and exitsCatch 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

TransportGood fitWatch out
sync://Explicit synchronous handling in tests or local code pathsNot async
DoctrineSimple deployments, low to moderate volume, local developmentDatabase polling and table growth
AMQPRabbitMQ, routing keys, priority-style topology, broker operationsBroker setup and AMQP extension
RedisRedis Streams, quick operational setup, stream consumersUnique consumer names and Redis memory
SQSAWS queue workloadsCloud-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:

<?php

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.

Top