A message fails after its retries run out, or arrives with a URN no handler maps.
BabelQueue routes it to a dead-letter queue that is an ordinary queue of canonical
envelopes, so a Go operator can triage a failure a PHP producer caused — and the
original trace_id survives the trip.
This is opt-in and additive. When you don’t enable it, nothing changes; when you
do, the envelope stays at schema_version: 1.
When a message goes to the DLQ
Three things send a message to the dead-letter queue:
- Permanent failure. A handler throws, the worker retries up to its policy,
and retries are exhausted. The
failed(...)hook runs first; then the message is dead-lettered. - An unroutable message when you set the unknown-URN strategy to
dead_letter(more on that below). - A poison body — undecodable JSON, or an oversized payload — handled best-effort so it can be inspected rather than retried forever or lost.
The DLQ is one mechanism for all three, across Redis and RabbitMQ. It does not
replace your framework’s native tools: Redis/Laravel permanent failures still
land in failed_jobs (php artisan queue:failed), and RabbitMQ’s broker-native
DLX still works if you configure it. The cross-language DLQ adds a
language-neutral, inspectable copy on top.
The four unknown-URN strategies
When a consumed message’s URN has no mapped handler, the configured strategy
decides what happens. Every SDK offers the same four, with the same names and
semantics (in Laravel, set via on_unknown_urn in config/babelqueue.php):
| Strategy | Behaviour |
|---|---|
fail (default) |
Throw UnknownUrnException; the worker retries, then the message moves to failed_jobs. |
delete |
Ack and drop the message — for shared queues where this service legitimately ignores some URNs. |
release |
Put it back on the queue after a configured delay — for rolling deploys where the handler may not be registered yet. |
dead_letter |
Quarantine it on the DLQ, then ack. Degrades to delete if the DLQ is disabled. |
One caution on release: if no service has the handler, releasing creates an
infinite redelivery loop. Use it only during a known migration window, with a
delay, and monitor it. When you want unroutable messages kept for triage rather
than retried or dropped, dead_letter is the strategy.
How a dead-lettered message is built
The DLQ holds the original envelope, preserved verbatim — same trace_id,
same meta.id, same data. On top of that, BabelQueue attaches one optional
top-level field, dead_letter, describing why the message failed.
That field appears only on DLQ messages, never on a normal queue. Because it is
additive and optional, the envelope stays at schema_version: 1, and any
consumer reading a normal queue ignores it. Here is a payment job that
dead-lettered after three attempts:
{
"job": "urn:babel:orders:charge",
"trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b",
"data": {
"order_id": 1042,
"amount_cents": 9990
},
"meta": {
"id": "f1e2d3c4-b5a6-4789-90ab-cdef01234567",
"queue": "orders",
"lang": "php",
"schema_version": 1,
"created_at": 1749132727000
},
"attempts": 3,
"dead_letter": {
"reason": "failed",
"error": "Payment gateway timeout",
"exception": "App\\Exceptions\\GatewayTimeout",
"failed_at": 1749132730000,
"original_queue": "orders",
"attempts": 3,
"lang": "php"
}
}
Everything above dead_letter is the message exactly as it was produced. The
block itself carries the failure context:
| Field | Meaning |
|---|---|
reason |
Why it was dead-lettered — failed, unknown_urn, or poison. |
error |
The exception message, or null. |
exception |
The fully-qualified exception type, or null. |
failed_at |
When it was dead-lettered, as Unix epoch milliseconds (UTC). |
original_queue |
The queue the message came from. |
attempts |
How many delivery attempts were made before it gave up. |
lang |
The language of the SDK that dead-lettered it. |
Note lang here is the SDK that failed the message, which can differ from the
meta.lang of the producer — a Python worker can dead-letter a job a PHP service
produced. The dead_letter shape and the queue naming are identical in every
SDK that supports a DLQ, verified by the shared conformance fixtures.
Naming and turning it on
The DLQ is disabled by default. Enable it, and a failed message goes to an
explicit configured queue, or else to <original_queue> plus a suffix (default
.dlq):
orders → orders.dlq
In the Laravel adapter that is the babelqueue.dead_letter config; a
DeadLetterPublisher does the routing when the dispatcher fails a job or when
the dead_letter unknown-URN strategy fires. Other SDKs mirror the same config
surface and the same naming.
Why a structured, language-neutral DLQ matters
failed_jobs is a PHP database table. A Go or Python operator cannot see or
triage failures from a PHP producer there — and for a polyglot product, “where do
dead messages go?” needs a language-neutral answer.
Making the DLQ an ordinary queue of canonical envelopes gives you that:
- Any SDK can read a failed message. The DLQ is just another queue; you consume it with the SDK you already run. No new infrastructure, no PHP runtime on the triage side.
- Correlation survives. The original
trace_idandmeta.idare preserved, so a dead-lettered message joins the same end-to-end trace as the rest of its causal chain. - The failure context travels with the message.
reason,error,exception, andattemptsare in the envelope, not in a sidecar log — so whoever inspects the DLQ, in whatever language, sees why it landed there.
One caveat: re-driving messages from the DLQ back to the source queue is an operator and tooling concern for v1, not a built-in command. The DLQ gives you a durable, inspectable, language-neutral record of every failure; replaying it is your call.
Takeaway
Turn the DLQ on, and failed or unroutable messages land on an ordinary queue any
of the six SDKs can read, each carrying a structured dead_letter reason and its
original trace_id. It is opt-in, additive, and keeps the envelope at
schema_version: 1 — so triage stops being a PHP-only problem.
Read the full structure in the
optional dead_letter block.