·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, 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_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 →

BabelQueue vs CloudEvents: two envelopes, different problems

CloudEvents standardizes how an event describes itself across protocols; BabelQueue freezes what a queue job looks like so six languages run it. An honest comparison — the real overlap, the real differences, and where CloudEvents is the better choice.

Read article →

What is a polyglot queue?

A polyglot queue is a message queue whose jobs are produced in one language and consumed natively in another. Why language-native serialization blocks that, the failure modes teams actually hit, and what a working solution has to provide.

Read article →