Monitoring Symfony Messenger Queues: Dead Letter Queues, Health Checks, and Alerts That Actually Fire
A Symfony Messenger queue does not fail the way a web request fails. There is no 500 status code, no stack trace in your error tracker, no obvious signal that anything is wrong. Messages just stop moving. A worker process dies and nothing restarts it. A webhook handler throws on every retry and the same message gets reprocessed forever, quietly eating CPU while the messages behind it wait. You find out when a customer emails asking why their invoice never arrived, three days after the queue backed up.
Symfony messenger monitoring is not optional once queues carry anything a user depends on: emails, invoice generation, webhook delivery, report exports. This post covers the parts that make queues observable: the metrics Messenger exposes, how to scrape them with Prometheus, dead letter queue configuration, a health check endpoint for your deployment platform, the Grafana panels worth building, the alert thresholds that catch problems early, and the recovery steps for when something does break.
Why queues fail silently
A queue consumer runs as a long-lived process, usually under Supervisor or systemd, pulling messages off a transport (Doctrine, Redis, or AMQP) in a loop. Three things tend to go wrong, and none of them show up in a normal application log dashboard.
The consumer process crashes and the process manager does not restart it. Messages keep arriving on the transport but nothing is there to pick them up, so the queue depth climbs and nobody notices until someone asks where their report is.
A specific message causes the handler to throw every time it is retried. Messenger's default retry strategy will retry a failing message a fixed number of times before sending it to the failure transport, but if failure transport routing is not configured, that message is retried and requeued indefinitely, and it burns through worker capacity that other messages need.
The failure rate for one message class creeps up gradually. Nothing crashes, but a growing share of a specific job type is failing on first attempt, usually because a downstream API changed its response shape or a database constraint started rejecting a subset of records. This kind of degradation is nearly invisible without per-class metrics.
Symfony messenger monitoring with Prometheus metrics
Symfony's MonologBridge and the Messenger component do not expose Prometheus metrics out of the box, so the usual approach is the promphp/prometheus_client_php library combined with Messenger's event dispatcher. Three metrics matter most:
Message count per transport and message class, as a counter, incremented in a listener on WorkerMessageHandledEvent and WorkerMessageFailedEvent. Label it by transport name and message class so you can see which queue and which job type a spike belongs to.
Processing time per message class, as a histogram. Wrap the handler dispatch with a timer and record the duration on WorkerMessageHandledEvent. A histogram, not a gauge, because you want percentiles: p50 tells you the normal case, p99 tells you when a handler is starting to choke under load before it fails outright.
Failure rate per message class, derived from the counter above by dividing failed count by total count in your Grafana query rather than tracked as its own metric. Keeping it as a ratio calculated at query time means you do not need to maintain two separate counters that can drift out of sync.
A minimal event subscriber looks like this:
final class MessengerMetricsSubscriber implements EventSubscriberInterface
{
public function __construct(private CollectorRegistry $registry) {}
public static function getSubscribedEvents(): array
{
return [
WorkerMessageHandledEvent::class => 'onHandled',
WorkerMessageFailedEvent::class => 'onFailed',
];
}
public function onHandled(WorkerMessageHandledEvent $event): void
{
$envelope = $event->getEnvelope();
$this->registry->getOrRegisterCounter(
'app', 'messenger_messages_total', 'Messages processed',
['transport', 'class', 'status']
)->inc([
$event->getReceiverName(),
$envelope->getMessage()::class,
'success',
]);
}
public function onFailed(WorkerMessageFailedEvent $event): void
{
$envelope = $event->getEnvelope();
$this->registry->getOrRegisterCounter(
'app', 'messenger_messages_total', 'Messages processed',
['transport', 'class', 'status']
)->inc([
$event->getReceiverName(),
$envelope->getMessage()::class,
$event->willRetry() ? 'retry' : 'failed',
]);
}
}
Expose these on a /metrics endpoint (typically behind network-level access control, not public) and add a Prometheus scrape job:
scrape_configs:
- job_name: 'symfony-messenger'
scrape_interval: 15s
static_configs:
- targets: ['app:9100']
You still need queue depth, which the metrics above do not give you directly since they only count messages after a worker has picked them up. Depth comes from the transport itself: Redis exposes LLEN on the underlying list, Doctrine's messenger table can be counted with SELECT COUNT(*) FROM messenger_messages WHERE queue_name = ? AND delivered_at IS NULL, and AMQP queues report their message count through the RabbitMQ management API. Poll whichever transport you use on a schedule (a cron-triggered command works fine at this scale) and push the value into a gauge.
Dead letter queues that actually catch failures
Messenger's failure transport is the built-in dead letter mechanism, and the default framework.yaml setup either omits it or routes everything to a single failed transport with no per-transport granularity. For anything beyond a toy project, configure a dedicated failure transport per queue that matters:
framework:
messenger:
failure_transport: failed
transports:
async_invoices:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000
multiplier: 2
failed:
dsn: 'doctrine://default?queue_name=failed'
The retry strategy above gives a message three attempts with exponential backoff (1s, 2s, 4s) before it lands in the failed transport. Once there, it stops consuming worker capacity and sits until someone looks at it, which is the entire point: a dead letter queue is not a place messages disappear to, it is a holding area for messages a human needs to inspect.
Do not let messages accumulate in the failed transport unmonitored. Track its depth the same way you track any other queue, and alert on it, because a growing failed queue almost always means a bug shipped, not that the messages themselves are unimportant.
A health check endpoint your deployment platform can use
Coolify, Kamal, and most container orchestrators support HTTP health checks, and a Messenger-aware one is worth more than the default /health that just confirms PHP-FPM is responding. Build an endpoint that checks queue depth against a threshold and returns an unhealthy status when it is exceeded:
#[Route('/health/queues', methods: ['GET'])]
public function queueHealth(QueueDepthChecker $checker): JsonResponse
{
$depths = $checker->getDepths();
$threshold = 5000;
$unhealthy = array_filter($depths, fn($d) => $d > $threshold);
if ($unhealthy !== []) {
return $this->json(['status' => 'unhealthy', 'queues' => $unhealthy], 503);
}
return $this->json(['status' => 'ok', 'queues' => $depths]);
}
Point your uptime monitor at this endpoint separately from your general application health check. A 503 here should not restart your web containers, since the web process is not what is backed up, but it should page whoever owns queue operations.
Grafana panels worth building
Four panels cover most of what you need day to day: a time series of queue depth per transport, so a slow climb is visible before it becomes an incident; a stacked bar of message throughput (success, retry, failed) per message class over the last hour; a heatmap of processing time percentiles per class, which surfaces a handler that is gradually getting slower under load; and a single-stat panel showing failed transport depth with a red threshold at whatever number you have decided means "someone needs to look at this today."
Keep the dashboard to those four. A monitoring dashboard nobody looks at because it has forty panels is worse than no dashboard, because it creates a false sense that someone is watching it.
Alert rules that fire before users notice
Three alert rules cover the failure modes described earlier. Queue depth above its normal baseline for five minutes catches the stuck-consumer case, since a healthy consumer pool drains a queue continuously and five minutes of sustained growth means something stopped consuming. Failure rate above 1% for a message class over a 15-minute window catches gradual degradation without triggering on a single transient blip. Consumer process count dropping to zero, checked via your process manager's own health signal or a Prometheus up metric on the worker's own metrics port, catches the crash case directly rather than waiting for its downstream symptom.
Write these as Prometheus alerting rules and route them through Alertmanager to whatever paging tool you use:
groups:
- name: messenger
rules:
- alert: QueueDepthHigh
expr: messenger_queue_depth > 5000
for: 5m
labels:
severity: warning
- alert: MessengerFailureRateHigh
expr: |
rate(app_messenger_messages_total{status="failed"}[15m])
/ rate(app_messenger_messages_total[15m]) > 0.01
for: 15m
labels:
severity: warning
- alert: MessengerWorkersDown
expr: up{job="messenger-worker"} == 0
for: 2m
labels:
severity: critical
Recovering from a failed queue
When messages do land in the failed transport, Symfony's console commands handle the recovery workflow. bin/console messenger:failed:show lists what is waiting, messenger:failed:show <id> -vv gives the full exception trace for one message, and messenger:failed:retry <id> requeues it to its original transport once you have fixed whatever caused the failure. For a bulk retry after deploying a fix, messenger:failed:retry --all reprocesses everything in the failure transport, though it is worth checking the failure reasons first since a batch retry after an incomplete fix just refills the failed queue with the same messages.
If a bad deployment is the cause and messages are actively failing in large volume, the faster move is often to pause the affected consumer entirely (stop the Supervisor program or scale the worker deployment to zero) rather than let it keep converting good messages into failed ones while you roll back. Messages queue up on the transport during the pause and resume processing once a fixed version is deployed and the consumer restarts, which is safer than a mass retry against code that still has the bug.
Queue monitoring is one of the areas where a small amount of upfront instrumentation saves a disproportionate amount of on-call stress later. If your Symfony application already runs Messenger in production and none of this is in place yet, it is worth an audit before the next silent backup finds you first. Wolf-Tech builds this kind of observability into custom Symfony applications and reviews existing setups as part of a broader code quality audit. Reach out at hello@wolf-tech.io or through wolf-tech.io if you want a second opinion on your queue setup.

