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, and it is broker-agnostic: Redis and
RabbitMQ are shown here, but the <queue>.dlq contract applies on every supported broker. 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.