Reliability & governance helpers

The Symfony adapter is a thin Messenger serializer on the framework-agnostic PHP core (babelqueue/php-sdk). It adds two Symfony-native pieces of wiring — an idempotency middleware and a trace-propagation middleware — and otherwise re-exposes the core’s optional reliability and governance helpers, which you apply at produce/consume time. None touch the frozen envelope (schema_version: 1); Messenger’s worker, retry and failure transport stay as they are. They are the Symfony face of the cross-SDK spec — follow each linked spec page for the full contract, and the php-sdk reference for the complete core API.

Idempotency (native middleware)

The adapter ships BabelQueue\Symfony\Messenger\IdempotencyMiddleware — the Messenger-idiomatic wrapper around the core’s Idempotent::wrap / ClaimingDispatch. It deduplicates a redelivered message on its canonical meta.id (carried on a BabelMessageIdStamp the serializer attaches on decode), so a handler runs once per logical message under at-least-once delivery. It acts only on the receive path; a fresh outbound dispatch passes straight through.

Enable it in the bundle config and register it on the consuming bus, before the handler middleware:

# config/packages/babelqueue.yaml
babelqueue:
    idempotency:
        enabled: true
        store: App\Babel\RedisIdempotencyStore   # a BabelQueue\Idempotency\IdempotencyStore service
        ttl: 3600                                  # in-flight claim TTL (ClaimingStore only)

framework:
    messenger:
        buses:
            messenger.bus.default:
                middleware:
                    - 'babelqueue.messenger.idempotency_middleware'

With a plain IdempotencyStore the middleware is post-success seen-set dedupe; with a ClaimingStore (a shared, persistent backend — the core’s PdoStore / RedisStore) it uses the stronger claim/commit/release lifecycle, so exactly one of N concurrent deliveries wins. A delivery that loses to an in-flight peer throws ClaimParkedException so Messenger does not ack it — the broker redelivers it later, by when the winner has committed. Left unset, store falls back to a bundled in-memory store (single-process / tests only). See Idempotency and the store deep-dive.

Transactional outbox

The adapter has no outbox of its own — use the core BabelQueue\Outbox helpers to remove the dual write between your Doctrine write and the publish. Persist the encoded envelope in the same DB transaction as your business row, then drain it with a relay (a messenger:consume worker on a dedicated bus, or a scheduled command):

use BabelQueue\Outbox\Outbox;

$em->wrapInTransaction(function () use ($order, $outbox, $envelope) {
    $em->persist($order);        // 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 Doctrine DBAL 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 a console command bound to your broker. The core ReplayBypass guard lets a deliberate replay re-run the idempotent core while skipping effects that already fired:

use BabelQueue\Redrive\ReplayBypass;

ReplayBypass::bypassExternalEffects($message, fn () => $this->sendEmail($data));

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 the message is dispatched/serialized, unprotect at the top of the handler — and validate cleartext on both sides. See GDPR field encryption.

OpenTelemetry (traceparent)

The adapter’s TracePropagationMiddleware already forwards an inbound trace_id onto any message a handler dispatches, keeping a chain of work in one trace (the v0.1 floor). The core BabelQueue\Otel\Tracing adds 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. 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.