Reliability & governance helpers
Last updated:
The Laravel driver is a thin adapter on the framework-agnostic PHP core, so the core’s
optional reliability and governance helpers are available to a Laravel app directly —
they live in babelqueue/php-sdk, which the driver already pulls in. None touch the frozen
envelope (schema_version: 1); each is a tooling layer that composes with your handlers and
the standard queue:work worker. They are the Laravel face of the cross-SDK spec — follow
the linked spec page for the full contract, and the
php-sdk reference for the complete API.
Idempotency
A handler that fires an external effect (a charge, an email) should be idempotent under
at-least-once delivery. On Laravel you do not wrap handlers yourself: enable consumer-side
dedupe in config/babelqueue.php and the dispatcher skips a redelivered meta.id
instead of re-running the handler — no handler change is needed.
// config/babelqueue.php
'idempotency' => [
'enabled' => true, // BABELQUEUE_IDEMPOTENCY_ENABLED, off by default
'store' => 'database', // 'redis' (default) | 'database' | 'memory'
'connection' => null, // Redis / DB connection name, null = the default one
'ttl' => 3600, // seconds an in-flight claim is held (crash backstop)
'table' => 'bq_idempotency',// 'database' store only
'prefix' => 'bq:idem:', // 'redis' store only
],
redis (RedisStore) and database (PdoStore) are claiming stores, so two workers
handed the same id never both run it; memory (InMemoryStore) is single-process only
(tests, one worker), never use it across a fleet. The redis store (the default) needs a
predis-backed connection — redis.client = 'predis' in config/database.php and predis/predis
installed; under Laravel’s default phpredis client it throws InvalidArgumentException, so
choose database there. For the database store, run the DDL once
at deploy time (for example from a migration):
DB::connection()->getPdo()->exec(\BabelQueue\Idempotency\PdoStore::ddl('bq_idempotency'));
To use your own backend, rebind BabelQueue\Idempotency\IdempotencyStore in the container
and the dispatcher dedupes through it. A message with no usable meta.id always runs
(fail-open). Use Idempotent::wrap($store, $handler) directly only with a raw core
consumer that receives a BabelQueue\Contracts\ConsumedMessage; it returns a
callable(ConsumedMessage): void, not something you call with ($data, $meta).
See Idempotency and the store deep-dive.
Dead letters and failed_jobs
Laravel keeps its own failure handling, and the driver does not take it away. Where a message
that can never succeed ends up depends on whether you turned the BabelQueue DLQ on
(dead_letter.enabled, BABELQUEUE_DLQ_ENABLED, off by default; see
Configuration):
| Message | dead_letter.enabled is false (default) |
dead_letter.enabled is true |
|---|---|---|
| a job whose retries are exhausted | failed into Laravel’s failed_jobs |
republished to <queue>.dlq with a dead_letter block |
a poison body (not JSON, not an envelope, or no valid job / urn) |
failed into failed_jobs |
republished to <queue>.dlq as the poison record (reason: "poison", original bytes kept) |
With the DLQ off, php artisan queue:failed lists these jobs and php artisan queue:retry
re-queues them exactly as it does for any Laravel job, and no <queue>.dlq record is written.
This is a deliberate exception to the cross-SDK rule that a poison body is dead-lettered in every
configuration (ADR-0037, since 1.10.0): the other SDKs have no
failed_jobs table, and Laravel’s native failure handling stays where you have not opted into
the BabelQueue DLQ. Enable the DLQ when a non-Laravel service has to triage poison bodies. (The contract verifies this
behaviour for the Redis driver; the other babelqueue-* drivers fall under the same exception.)
The same exception covers a version-gated delivery on the babelqueue-sqs and Artemis
drivers (a bq-schema-version or bq_schema_version value other than the supported one), whose
behaviour is unchanged. With the DLQ enabled the driver publishes a stub envelope with a
dead_letter block (reason: "poison") to <queue>.dlq (<queue>-dlq on an Amazon SQS connection) and acknowledges the delivery; with it
disabled the delivery throws InvalidEnvelopeException, so the worker’s retry and then
failed_jobs take it. on_unknown_urn is ignored on this path. This is not the raw-body
record that the Go, Python, Java, .NET and Node consumers write for a version-gated delivery, so
a reader of <queue>.dlq (<queue>-dlq on SQS) must not assume a Laravel-written record holds the original bytes.
Transactional outbox
To remove the dual write between your Eloquent write and the publish, persist the encoded envelope in the same DB transaction, then drain it with a relay (a scheduled command or queued job):
use BabelQueue\Outbox\Outbox;
\DB::transaction(function () use ($order, $outbox) {
$order->save(); // business row
$outbox->write($envelope); // the message, same transaction — no commit of its own
});
Outbox, OutboxRelay and the four-method OutboxStore (save / fetchUnpublished /
markPublished / markFailed) live in BabelQueue\Outbox; back the store with a PDO/ORM
adapter whose save() runs on the open transaction. See
Transactional Outbox.
DLQ redrive & replay-bypass
BabelQueue\Redrive\Redrive::run($io, $dlq, $options) moves dead-lettered messages back onto
a queue (reset, dry-run, sandbox toQueue, select, bypass) — typically from an Artisan
command. The ReplayBypass guard lets a deliberate replay re-run the idempotent core while
skipping effects that already fired:
use BabelQueue\Redrive\ReplayBypass;
// $headers is the out-of-band transport header map (string => string) you hold.
if (! ReplayBypass::isReplayHeaders($headers)) {
$this->sendEmail($data); // skipped on a replay; the idempotent core still re-runs
}
ReplayBypass::bypassExternalEffects() and isReplay() take a core HasHeaders message, and
today only the core KafkaMessage implements it. In Laravel the delivered message
(PolyglotMessage) does not expose transport headers yet, so the guard is only usable where
you hold the raw headers yourself, for example a core KafkaMessage, or a header map your own
worker surfaced; use isReplayHeaders($headers) there.
See DLQ redrive & replay-bypass.
GDPR field encryption
BabelQueue\Gdpr\Gdpr::protect() / ::unprotect() encrypt only the data leaves a schema
marks x-gdpr-sensitive, leaving data pure JSON and the envelope frozen. OpenSslCipher
(over ext-openssl) is the reference; bind your own Cipher to a KMS in production. Protect
before publishing, unprotect at the top of the handler — and validate cleartext on both
sides. See GDPR field encryption.
OpenTelemetry (traceparent)
BabelQueue\Otel\Tracing emits publish <urn> / process <urn> spans and, on a
header-carrying transport (Redis/AMQP/SQS), injects/extracts the W3C traceparent so a
consumer span is a true child of the producer span — degrading to v0.1 trace_id
correlation otherwise. The trace_id already threaded through your handler logs is the v0.1
floor. See Observability.
Per-URN schema validation
BabelQueue\Schema\SchemaValidated::assert() (producer guard) / ::wrap() (consumer safety
net) validate a message’s data against the JSON Schema registered for its URN, bridged from
a babelqueue-registry manifest via DirProvider — run bqschema (including the
gdpr --require PII audit) in CI. See Per-URN schema validation.