babelqueue/php-sdk
Framework-agnostic core of BabelQueue for PHP: the canonical polyglot queue envelope codec, contracts, validation, transports, dead-letter and idempotency helpers that all framework adapters (Laravel, Symfony) build on. Install directly only for framework-less apps or new adapters.
Install the adapter, not the core. In a Laravel app the entry point is composer require babelqueue/laravel; this core (babelqueue/php-sdk) is pulled in transitively. Only composer require babelqueue/php-sdk directly if you're building a framework-less service or writing your own adapter. Note the Laravel adapter itself is not documented in this core README — read its own docs, and come back here for the wire format and helper behaviour.
Read these four files first, in this order:
BabelQueue\Codec\EnvelopeCodec — the single PHP implementation of the wire envelope ({job, trace_id, data, meta, attempts}, schema_version 1). Everything else is built around it.BabelQueue\Contracts\PolyglotJob — the two methods you actually implement: getBabelUrn() + toPayload().BabelQueue\Validation\EnvelopeValidator — consumer-side gate that returns a reason, not a boolean.BabelQueue\Contracts\Transport — one method; tells you what a broker binding must do.First useful thing to build: a PHP producer / Go consumer pair. This is the entire point of the package — dropping serialize().
use BabelQueue\Codec\EnvelopeCodec;
use BabelQueue\Contracts\PolyglotJob;
final class OrderCreated implements PolyglotJob
{
public function __construct(private array $order) {}
public function getBabelUrn(): string { return 'urn:babel:orders:created'; }
public function toPayload(): array { return ['order_id' => $this->order['id'], 'total' => $this->order['total']]; }
}
$body = EnvelopeCodec::encode(EnvelopeCodec::make('urn:babel:orders:created', $job->toPayload(), 'orders'));
Publish that string over whatever transport the adapter uses, then point a non-PHP consumer at the same queue and assert it reads the identical JSON. Do this on day one — it validates your whole reason for adopting the package before you invest in the harder pieces (idempotency, outbox, DLQ).
Model messages as DTOs, not arrays. Implement PolyglotJob per message type and let EnvelopeCodec::fromJob($job, 'queue') build the envelope. The URN becomes your routing key in the adapter and your ClaimingDispatch::on(...) / redrive selector later — treat it as a stable public identifier, since a Go or Python producer will emit the same string.
Validate before dispatch, always. EnvelopeValidator::check() returning a reason string is the design decision that matters operationally: unsupported_schema_version means a newer producer shipped — dead-letter it rather than return-ing silently into the void.
if ($reason = EnvelopeValidator::check($envelope = EnvelopeCodec::decode($raw))) {
$this->quarantine($envelope, $reason); // do not ack-and-drop
return;
}
Pick your idempotency tier deliberately. Idempotent::wrap(new InMemoryStore(), $handler) is fine for a single local worker and tests; for anything with more than one process, jump straight to ClaimingDispatch::wrap(new PdoStore($pdo), $handler). In Laravel that PDO is trivially DB::connection()->getPdo(); run $pdo->exec(PdoStore::ddl()) in a migration and you're done with no new dependency. RedisStore is the alternative if you already have predis. The claim (INSERT unique-key / SET NX PX) is what stops two workers handed the same meta.id from both running.
Use the outbox when the business write and the publish must agree. Bind OutboxStore (four methods) to your DB, then:
DB::transaction(function () use ($outbox, $order) {
Order::create($order); // business write
$outbox->write(EnvelopeCodec::make('urn:babel:orders:created', $order->toArray(), 'orders'));
});
Relay in a scheduled command or a dedicated worker via new OutboxRelay($transport, $store) → drain(). The stored envelope is relayed verbatim, so trace_id survives end-to-end.
Separate the idempotent core from external effects, then hook replay-bypass in. This is the pattern worth adopting even before you have a DLQ:
ReplayBypass::bypassExternalEffects($m, fn () => Mail::to($m->email)->send(new Confirmation($m)));
saveOrder($m) always runs; the email is skipped on a redrive. Requires the redrive side to use a HeaderRedriveIO — see gotchas.
GDPR: validate cleartext, then protect. The order matters and is easy to get wrong in a service class. On produce: SchemaValidated::assert(...) → Gdpr::protect($data, $schema, $cipher) → EnvelopeCodec::encode(...). On consume: decode → Gdpr::unprotect($data, $schema, $cipher) → validate/read. Bind a Cipher to your KMS in a container singleton; use OpenSslCipher only for local/test.
Dead-letter annotations and URN strategy are cheap wins. DeadLetter adds the additive dead_letter block; UnknownUrnStrategy gives you the four constants (fail / delete / release / dead_letter) so the unknown-URN policy is config, not a match scattered across handlers.
Tracing is opt-in. Wrap produce/consume in Tracing spans when you want cross-language correlation via trace_id, and rely on the traceparent header seam when the broker carries headers. Don't adopt it in the same PR as the transport migration.
schema_version stays 1; trace_id and the shape don't change for new features. Anything new rides beside the envelope through HeaderPublisher / HasHeaders (replay-bypass, traceparent). If you find yourself wanting to add a key to data for plumbing, you want a header instead.predis (Redis), php-amqplib (AMQP), ext-openssl (reference cipher), open-telemetry/api (tracing) are all suggests. The core is ext-json only. Install precisely what you use; don't drag a Redis client into a Laravel app that already has one unless RedisStore needs it.data before unprotecting will see a string where it expected an object.HeaderRedriveIO. Over a plain RedriveIO the stamp can't be delivered and item->bypassed is false — meaning your "skipped" external effect actually fired. Check bypassed in redrive reporting, and still make external effects (vendor API idempotency keys) independently safe.ClaimParkedException must not ack. It means a concurrent delivery holds the claim; let it redeliver. Swallowing it looks like success and stalls the message.RedisTransport is one RPUSH. No visibility timeout, no ack, no retry semantics. It's a reference seam, not a queue abstraction — don't let its simplicity set your expectations for what the transport does. phpredis users are expected to implement Transport themselves; it's a single method.data is the failure mode this package exists to prevent, and it'll only surface at the boundary.tests/fixtures/ holds the golden envelopes every SDK must round-trip. If you write a custom Transport or adapter, round-trip those fixtures in your own test suite; with 1 star and 0 dependents, that suite is your real spec.EnvelopeValidator::check() reason? → URN registered in the adapter's routing map? → ClaimingDispatch::on() key matching the producer's URN exactly (typos here fail silently as unknown-URN)? → claim already held (parked)?How can I help you explore Laravel packages today?