babelqueue / laravel
Laravel adapter for BabelQueue: a drop-in polyglot queue driver. PHP produces strict JSON envelopes that Go, Python, Java, .NET and Node consumers read without PHP's serialize(). Built on babelqueue/php-sdk.
Fund package maintenance!
Requires
- php: ^8.2
- babelqueue/php-sdk: ^1.23.0
- illuminate/queue: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
- php-amqplib/php-amqplib: ^3.7
Requires (Dev)
- aws/aws-sdk-php: ^3.0
- larastan/larastan: ^3.0
- orchestra/testbench: ^9.0|^10.0
- phpunit/phpunit: ^10.5|^11.0
- predis/predis: ^2.2 || ^3.0
- stomp-php/stomp-php: ^5.0
Suggests
- aws/aws-sdk-php: Required for the drop-in Amazon SQS driver (babelqueue-sqs).
- stomp-php/stomp-php: Required for the drop-in Apache ActiveMQ Artemis driver (babelqueue-artemis), the §7 STOMP path.
Provides
None
Conflicts
- aws/aws-sdk-php: <3.306.2
Replaces
None
README
Polyglot Queues, Simplified. A drop-in Laravel queue driver that produces a strict, language-agnostic JSON envelope — so Go, Python, Java, .NET and Node.js services can consume your jobs without PHP's
serialize().
Laravel's native queue serialises jobs with PHP serialize(), producing an
object graph only PHP can read. BabelQueue replaces just the serialization
layer with a frozen JSON envelope and URN-based routing, over the broker you
already run (Redis or RabbitMQ). No sidecar, no proxy, no broker plugin.
This is the PHP/Laravel SDK. The full cross-language standard and the canonical wire contract are documented at babelqueue.com.
Requirements
- PHP
^8.2 - Laravel
^11.0 | ^12.0 - Redis or RabbitMQ
Installation
composer require babelqueue/laravel php artisan vendor:publish --tag=babelqueue-config
Add a polyglot connection to config/queue.php:
'connections' => [ 'bq-redis' => [ 'driver' => 'babelqueue-redis', 'connection' => 'default', // an illuminate/redis connection 'queue' => 'default', 'retry_after' => 90, ], // RabbitMQ alternative 'bq-rabbit' => [ 'driver' => 'babelqueue-rabbitmq', 'host' => env('RABBITMQ_HOST', '127.0.0.1'), 'port' => env('RABBITMQ_PORT', 5672), 'user' => env('RABBITMQ_USER', 'guest'), 'password' => env('RABBITMQ_PASSWORD', 'guest'), 'vhost' => env('RABBITMQ_VHOST', '/'), 'queue' => 'default', 'exchange' => '', 'exchange_type' => 'direct', ], // Apache ActiveMQ Artemis over STOMP (the §7 path; requires stomp-php/stomp-php). // Consumes messages produced by the Java (JMS) / .NET / Node / Python / Go Artemis // SDKs — Artemis bridges STOMP ↔ AMQP 1.0 ↔ JMS on the same address. 'bq-artemis' => [ 'driver' => 'babelqueue-artemis', 'host' => env('ARTEMIS_HOST', '127.0.0.1'), 'port' => env('ARTEMIS_STOMP_PORT', 61613), 'username' => env('ARTEMIS_USER', 'artemis'), 'password' => env('ARTEMIS_PASSWORD', 'artemis'), 'queue' => 'default', 'destination_prefix' => '', // e.g. '/queue/' if your Artemis needs the anycast prefix 'read_timeout' => 1, // seconds a pop() waits before returning empty ], ],
The wire envelope
Every message is encoded as this frozen, schema_version: 1 envelope
(full spec at babelqueue.com):
{
"job": "urn:babel:orders:created",
"trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b",
"data": { "order_id": 1042, "amount": 99.90 },
"meta": { "id": "…", "queue": "default", "lang": "php", "schema_version": 1, "created_at": 1749132727000 },
"attempts": 0
}
job— the message URN, never a class name. Convention:urn:babel:<context>:<event>.trace_id— cross-service correlation id, preserved across every hop.data— your pure-JSON payload.
Producing messages
Typed job (primary):
use BabelQueue\Contracts\ShouldQueuePolyglot; final class CreateOrder implements ShouldQueuePolyglot { public function __construct(private int $orderId, private float $amount) {} public function getBabelUrn(): string { return 'urn:babel:orders:created'; } public function toPayload(): array { return ['order_id' => $this->orderId, 'amount' => $this->amount]; } } CreateOrder::dispatch(1042, 99.90)->onConnection('bq-redis');
Facade (sugar):
use BabelQueue\Facades\BabelQueue; BabelQueue::publish('urn:babel:orders:created', ['order_id' => 1042, 'amount' => 99.90]);
Continuing a trace from a handler? Implement BabelQueue\Contracts\HasTraceId on
the downstream job (or pass the traceId argument) and the inbound trace_id
is forwarded instead of a new one being minted.
Consuming messages
Map URNs to handlers in config/babelqueue.php:
'handlers' => [ 'urn:babel:orders:created' => \App\Consumers\OnOrderCreated::class, ],
final class OnOrderCreated { // $data, $meta, $traceId and $message are injected by name. public function handle(array $data, array $meta, string $traceId): void { logger()->info('order created', ['id' => $data['order_id'], 'trace' => $traceId]); } // Optional: called once when retries are exhausted. public function failed(array $data, ?\Throwable $e): void { report($e); } }
Run a worker against the polyglot connection like any other:
php artisan queue:work bq-redis
Unknown URNs
config/babelqueue.php → on_unknown_urn: fail (default) · delete ·
release · dead_letter.
on_unknown_urn applies to messages whose URN has no mapped handler and that pass
the schema-version check (a body that is not JSON, and so has an empty job, also
lands here). The meta.schema_version check runs before URN routing and is
independent of this setting: an envelope whose meta.schema_version is absent or
not 1 is never handed to a handler and never reaches on_unknown_urn — even
with delete, release or dead_letter. With the dead-letter queue enabled it
is published to the configured DLQ (default <queue>.dlq) with reason poison and acked; otherwise an
InvalidEnvelopeException (unsupported_schema_version) is thrown so the
worker's retry / failed_jobs path records it.
Shared queues. A queue that also carries traffic this service does not own (for example a stock Laravel job pushed onto a
babelqueue-*connection, or another team'sschema_version: 2messages) can no longer be silenced withon_unknown_urn: delete: such messages used to be dropped silently and now surface aspoisonDLQ entries or as failed jobs. Give this service its own queue or connection instead. (Before this change they were deleted without a trace.)
SQS: bq-schema-version message attribute
The babelqueue-sqs driver requests the message attributes on every receive
(MessageAttributeNames = ['All']) and checks bq-schema-version before
the body is decoded (broker-bindings §3.5 / §3.7). A missing or blank attribute
(empty or ASCII whitespace only) falls through to the normal path, where
meta.schema_version is checked as above. Any other value that is not exactly
1 is never decoded and never reaches a handler: it takes the same
poison DLQ / InvalidEnvelopeException route as an unsupported
meta.schema_version.
Dead-letter queue (cross-language)
Enable in config/babelqueue.php:
'dead_letter' => [ 'enabled' => true, 'suffix' => '.dlq', // failures from "orders" go to "orders.dlq" ],
Permanently-failed (and, with on_unknown_urn => 'dead_letter', unroutable)
messages are republished to the DLQ as the same envelope plus an additive
dead_letter block (reason, error, failed_at, original_queue,
attempts, lang). Because the DLQ is an ordinary queue, any SDK can triage it.
Idempotent consumption (opt-in)
Brokers deliver at least once: a redelivery after a missed ack — or a
fan-out — can hand a consumer the same message twice. Enable idempotency to
dedupe deliveries on the envelope's canonical per-message identity, meta.id,
so a duplicate delivery is a no-op and the first delivery runs the handler
exactly once. It is off by default and never changes the wire envelope.
Enable in config/babelqueue.php:
'idempotency' => [ 'enabled' => true, 'store' => 'redis', // 'redis' | 'database' | 'memory' 'connection' => null, // Redis/DB connection name (null = its default) 'ttl' => 3600, // in-flight claim TTL (seconds) — crash backstop ],
The default backends are Laravel-native and reuse infrastructure you already run:
redis— over a Laravel Redis (predis) connection.database— over a Laravel DB connection (PostgreSQL / MySQL / SQLite). Create the table once at deploy time:DB::connection(config('babelqueue.idempotency.connection')) ->getPdo() ->exec(\BabelQueue\Idempotency\PdoStore::ddl(config('babelqueue.idempotency.table')));
memory— single-process only (tests / a lone worker); not shared, not persistent.
redis and database are claiming stores: when two workers receive the same
meta.id concurrently, exactly one runs the handler and the other parks for
redelivery — closing the in-flight window, not just deduping after success.
It binds the php-sdk IdempotencyStore in the container, so you can rebind it to
a custom store:
$this->app->instance(\BabelQueue\Idempotency\IdempotencyStore::class, new MyStore());
A thrown handler leaves the id unmarked, so retry / DLQ still apply and a
later delivery re-runs it. A message with no usable meta.id always runs
(fail-open).
Known limits
Redis retry (babelqueue-redis). pop() delegates to Laravel's RedisQueue::pop, whose atomic
reserve/migrate step is a Lua script that decodes and re-encodes the body with Redis' bundled cjson. On every retry
the body that goes back to Redis (and, via :delayed, to polyglot consumers) is therefore rewritten by cjson, not by
BabelQueue:
- integers with more than about 14 significant digits (order or payment IDs,
int64values) lose precision; - an empty
[]is re-encoded as{}; - an inbound
urnalias is not moved intojob, and the keys that message-envelope.md section 10 forbids (timestamp,meta.ts,meta.max_retries,meta.attempts,meta.source) are not removed.
This deviates from ADR-0031 (b)/(c), which ask for a re-emit that is byte-safe and strips forbidden keys. It predates
1.23 and is not fixed here, because it needs a BabelQueue-owned pop/release Lua script (a design decision, tracked as a
follow-up). The RabbitMQ, Artemis and SQS drivers are not affected: RabbitMQ and Artemis republish the original body
(only the alias and the forbidden keys are edited), SQS never rewrites it. Until then, keep identifiers longer than 14 digits
as strings in data when a job can be retried on Redis.
Testing
composer install vendor/bin/phpunit
Links
- Website & docs (incl. the canonical wire contract): https://babelqueue.com
- Changelog: CHANGELOG.md
License
MIT © Muhammet Şafak. See LICENSE.