pccomponentes/messenger-retry-bundle
A Symfony bundle for Messenger that adds flexible retry handling for failed or transiently failing messages. Configure retry strategies and requeue messages through Symfony's transports and failure transport, keeping async workloads resilient without custom glue code.
The 5-minute setup (Symfony app — the package's native home):
composer require pccomponentes/messenger-retry-bundleconfig/bundles.php:
return [
// ...
Pccomponentes\MessengerRetryBundle\MessengerRetryBundle::class => ['all' => true],
];
symfony/messenger configured with at least one async transport and a failure transport — this bundle augments that setup, it does not replace it.framework:
messenger:
transports:
async_http:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 5
delay: 1000
multiplier: 2
max_delay: 60000
failure_transport: failed
Important context before step 1 if you are coming from Laravel (see Gotchas): this is a Symfony bundle, not a Laravel package. It is designed to be registered via Symfony's Kernel / bundle system. To use it inside a Laravel app you first need a Symfony Messenger bridge (e.g. laravel/symfony-messenger-style integrations) that boots a Symfony container for Messenger — otherwise the bundle's Extension and DI compiler passes will never run. If you are simply using Laravel queues, stop here and use $tries / $backoff / failed_jobs instead.
First use case: wrap an unreliable outbound integration — a flaky payment provider, a slow email API, a partner webhook — with a bounded exponential retry so transient 5xx/connection errors do not become lost work, and exhausted messages land in a failed transport you can inspect and replay.
Where to look first in the repo: the bundle's Extension / configuration tree (to see the exact keys available under retry_strategy) and the Messenger middleware it registers in the pipeline. There is no elaborate docs site — the config schema is the documentation, so read DependencyInjection/Configuration.php first.
Pattern 1 — Per-transport policy (the primary use case).
Put the retry strategy on the transport DSN, not on the dispatcher. Every message routed to async_http inherits the policy automatically:
async_http:
dsn: '%env(HTTP_QUEUE_DSN)%'
retry_strategy:
max_retries: 5
delay: 1000
multiplier: 2
max_delay: 60000
Pattern 2 — Tiered retry classes. Do not use one policy for everything. Create two or three transports with different aggressiveness:
async_fast — cheap, idempotent work: max_retries: 2, delay: 500, multiplier: 2.async_external — third-party APIs: max_retries: 5, delay: 2000, multiplier: 3, max_delay: 300000.async_expensive — batch/ETL: max_retries: 8, delay: 5000, multiplier: 2, max_delay: 3600000.Route messages by dispatch-time $bus (async_external) rather than relying on per-message config. This keeps the policy in YAML (visible to ops) and out of your DTOs.
Pattern 3 — Per-message overrides. Where the bundle exposes per-message retry (via a marker interface / attribute on the message class), use it sparingly — only when a single message has a materially different failure profile from its transport peers. Document the override in the message class docblock so a future reader knows the transport default does not apply.
Pattern 4 — Failure transport as a first-class workflow, not an afterthought.
failure_transport: failed
Then build a messenger:failed:show / messenger:failed:retry habit. Your on-call runbook should include: “check failed transport count; if spiking, look at the shared dependency, not the messages.” A retry bundle is only as good as your ability to see the terminal failures.
Pattern 5 — Handler idempotency as a paired discipline. Retry without idempotency is a data-corruption generator. For each retried handler:
If the handler is not idempotent, do not give it max_retries > 0.
Pattern 6 — Middleware composition. The bundle plugs into the Messenger middleware stack, so it composes with your own middleware (tracing, logging, tenancy, rate-limiting). Register yours with priority below the retry middleware if you want the retry to re-enter your middleware on each attempt (typical for tracing), or above it if you want the middleware to see only the logical dispatch (typical for metrics — count one dispatch, not N retries).
Workflow for adopting it on an existing app:
failed transport before turning retries on — no exceptions.The framework mismatch — read this first.
The package is a Symfony bundle. A Laravel app that has not adopted Symfony Messenger will not be able to use it at all: there is no service provider here, no config/messenger-retry.php, no php artisan command. If your Laravel codebase relies on ShouldQueue / Illuminate\Queue, the correct answer is Laravel's built-in $tries, $backoff (array for explicit backoff), and failed_jobs table. Adopt this bundle only if you have deliberately introduced Symfony Messenger for cross-framework messaging or are on a Symfony app.
If you are on Laravel and using Symfony Messenger via a bridge:
php artisan config:cache surprises: the bundle reads Symfony-style YAML/DI config, not Laravel's config/*.php. Your config:cache will not invalidate it, but a stale Symfony container cache will. Clear both.Horizon and failed_jobs do not know about your Messenger failed transport. You now have two failure surfaces to monitor. Instrument both.Backoff semantics and jitter.
Exponential backoff (delay × multiplier^attempt, capped at max_delay) tends to produce synchronization — a thousand messages that all failed at once will all retry at once. The bundle does not document jitter. If your failures are correlated (a downstream outage), add a randomized delay around max_delay via a custom middleware, or accept the “thundering herd on recovery” risk consciously.
max_delay is your safety valve.
Without it, multiplier: 3 at attempt 10 gives you a multi-hour wait. Always set max_delay. A good rule of thumb: max_delay should be shorter than your business SLA for the message (e.g. “password reset email must arrive within 30 min” → max_delay: 60000, max_retries: 4).
Message serialization is coupled to retries. Retried messages are re-read from the transport. If your message holds a Doctrine entity or an unserializable resource, the second attempt will fail deserialization — not the first. Test by dispatching a message, letting it fail once, and inspecting the failed transport payload. Store scalar IDs, not entities.
Failure transport routing is per-bus.
If you have multiple buses, confirm each one has a failure transport, or max_retries exhaustion will drop messages on the floor for the ones that don't.
Semver warning from the upstream 2.1.1 release.
Symfony 7 support was shipped as a patch bump. If you float ^2.1, a composer update can pull a version with a new Symfony major in the constraint. Pin the package (2.1.1) if you are not actively migrating to Symfony 7, and review composer.lock diffs in PRs.
Debugging the retry loop.
php bin/console messenger:stats (or the Laravel equivalent via the bridge) shows queue depth per transport.messenger:failed:show <transport> lists terminal failures.LoggerInterface injection in a middleware and log attempt, delay, and originalMessageId — the bundle does not emit much by default.messenger:consume -vv in staging; you will see Retrying lines from Symfony's own retry middleware, which this bundle configures.Extension points to use rather than fork.
MiddlewareInterface for jitter, tracing, or dead-letter tagging — registered above/below the retry middleware.RetryStrategyInterface on the transport if you need non-exponential behaviour (e.g. fixed delay for rate-limited APIs). This is Symfony's own extension point and the bundle respects it.RetryStrategyInterface is per-transport, so you can mix the bundle's default with your own on a rate_limited transport without conflict.Operational tip that pays for itself: before enabling retries on a transport, publish the expected backoff curve (attempt → delay) in the team runbook. When an incident fires, the on-call engineer needs to know whether the message is between attempts (leave it alone) or exhausted (replay it). The bundle does not surface this; you must.
How can I help you explore Laravel packages today?