·5 min read ·Muhammet Şafak

Cross-language dead-letter queues

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.

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_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.

Publishing polyglot jobs from Laravel

Add BabelQueue to a Laravel app and publish jobs that Go, Python, Node, Java and .NET services consume natively — without changing your broker.

Read article →

How we measure the 2% overhead budget

BabelQueue holds the codec to under 2% over plain JSON. Here's the marginal-cost method behind that number, why it's measured without a broker, and what it really costs.

Read article →