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.