--- title: "Cross-language dead-letter queues" description: "Route failed and unroutable messages to a dead-letter queue any language can read, with a structured reason — opt-in, additive, and still schema_version 1." source: https://babelqueue.com/blog/cross-language-dead-letter-queues/ pubDate: 2026-08-27T00:00:00.000Z --- A message fails after its retries run out, or arrives with a URN no handler maps. BabelQueue routes it to a [dead-letter queue](/glossary/#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: ```json { "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 `` 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_id` and `meta.id` are 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`, and `attempts` are 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](/docs/spec/1.x/envelope/#optional-dead_letter-block).