symfony/redis-messenger
Redis Messenger provides Redis integration for Symfony Messenger, letting you use Redis streams as a transport for sending and consuming messages in your Symfony applications. Lightweight and fast, it's a drop-in option for async message handling.
This is not a Laravel package. It is the Redis transport bridge for Symfony Messenger, so "using it in Laravel" means either (a) interoperating with a Symfony service that already publishes to Redis Streams, or (b) deliberately adopting symfony/messenger inside a Laravel app because you need Redis Streams consumer groups (XADD/XREADGROUP/XACK/XAUTOCLAIM semantics) rather than Laravel's simple LPUSH/BRPOP Redis queue. If you just want "jobs on Redis" in Laravel, Illuminate\Queue\RedisQueue is the right tool — this bridge buys you at-least-once delivery with a pending-entries list, competing consumers via consumer groups, and redelivery of unacked messages.
Minimal install. Branch choice matters more than anything: Symfony 8.x requires PHP >= 8.4, so most Laravel 11/12 apps land on the 7.4 line.
composer require symfony/messenger:^7.4 symfony/redis-messenger:^7.4 --with-all-dependencies
You also need the ext-redis (or ext-relay) C extension — this bridge instantiates \Redis/\Relay\Relay directly and does not support predis. Many Laravel apps default to predis; check php -m before you start.
Where to look first. Read Symfony\Component\Messenger\Bridge\Redis\Transport\Connection::fromDsn() — every option, default, and DSN query parameter lives there, and the Symfony docs page for the Redis transport is thin on purpose. Then RedisReceiver (the get()/ack()/reject() loop) and RedisTransportFactory.
DSN anatomy. Path segment = stream name; everything else is a query option:
redis://127.0.0.1:6379/messages
?consumer=laravel-web-1 # unique per worker process — set this explicitly
&group=laravel # consumer group; must be consistent across all workers
&auto_setup=1 # XGROUP CREATE MKSTREAM if missing
&delete_after_ack=1 # XDEL on ack instead of leaving entries in the stream
&stream_max_entries=100000 # approximate MAXLEN trim on XADD
&redeliver_timeout=3600 # seconds before a pending message is reclaimable
&claim_interval=60000 # ms between XAUTOCLAIM sweeps
&lazy=1
First use case — bypass the bus entirely. The fastest way to understand the bridge is to talk to the transport directly, without MessageBus, middleware, or messenger:consume:
use Symfony\Component\Messenger\Bridge\Redis\Transport\Connection;
use Symfony\Component\Messenger\Bridge\Redis\Transport\RedisTransport;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Transport\Serialization\PhpSerializer;
$transport = new RedisTransport(
Connection::fromDsn('redis://127.0.0.1:6379/messages', [
'group' => 'laravel',
'consumer' => 'laravel-'.gethostname().'-'.getmypid(),
'auto_setup' => true,
'delete_after_ack' => true,
'stream_max_entries' => 100_000,
]),
new PhpSerializer(),
);
$transport->send(new Envelope(new SendWelcomeEmail($userId)));
while (true) {
foreach ($transport->get() as $envelope) {
try {
dispatch_locally($envelope->getMessage());
$transport->ack($envelope);
} catch (\Throwable $e) {
$transport->reject($envelope);
}
}
usleep(100_000);
}
That is the whole contract: send, get, ack, reject. Everything else is Messenger middleware layered on top.
Pattern 1 — Thin transport adapter in a service provider. Wrap construction once and let the rest of the app inject TransportInterface. Register a singleton keyed by DSN so config lives in config/messenger.php rather than scattered strings:
$this->app->singleton(RedisTransport::class, fn () => (new RedisTransportFactory())->createTransport(
config('messenger.dsn'),
config('messenger.options'),
new PhpSerializer(),
));
Pattern 2 — Full Symfony bus inside Laravel. When you want the middleware pipeline (retry, SendMessageMiddleware, HandleMessageMiddleware, failure transport), build a MessageBus and run Symfony's Worker from an Artisan command. This is where you actually get parity with messenger:consume:
$bus = new MessageBus([
new HandleMessageMiddleware(new HandlersLocator([
SendWelcomeEmail::class => [fn (SendWelcomeEmail $m) => app(EmailService::class)->welcome($m->userId)],
])),
]);
$worker = new Worker(['async' => $transport], $bus, $this->laravelEvents);
pcntl_async_signals(true);
pcntl_signal(SIGTERM, fn () => $stop = true);
$worker->run(['sleep' => 500_000, 'time-limit' => 3600, 'memory-limit' => '128M']);
Register StopWorkerOnSigtermSignalListener (or your own flag) so supervisorctl restart doesn't kill a message mid-handler and force a redelivery cycle.
Pattern 3 — Bridge it into Laravel's queue API. You cannot hand a TransportInterface to Queue::extend() as-is; Laravel expects an Illuminate\Contracts\Queue\Queue implementation (push, later, pop, ack, release) plus its own JSON envelope (uuid, displayName, job, data, attempts). The workable adapter wraps the Laravel payload in a single QueuedJob message, base64-encodes the serialized job into the Symfony envelope, and maps release() to reject()-with-attempts. Budget a day; keep it in-house rather than pretending it's drop-in.
Pattern 4 — Interop with an existing Symfony app. If a Symfony service already publishes to messages, set the same group and use SymfonySerializer with a JSON encoder instead of PhpSerializer. Cross-app PHP serialization breaks on any class mismatch, autoload difference, or vendor version drift. Shared DTOs + JSON = survivable; PHP serialize = fragile.
Pattern 5 — Failure handling and DLQ. Pair the transport with a failure_transport pointing at a second Redis stream (or Doctrine). Since 8.1/backported, RedisReceiver implements ListableReceiverInterface, so messenger:failed:show / :retry work against Redis — in Laravel you'd reimplement those two commands, but all() and find() on the receiver give you what you need.
Operator loop. One supervisor program per consumer group member, numprocs=4, each with a distinct consumer value (env-inject %program_name%_%(process_num)s). Monitor with XLEN, XPENDING, and XINFO GROUPS — if pending climbs and never drains, your handlers are dying or the redeliver timeout is longer than your patience.
Batch consumers. The 8.1 --fetch-size option lets messenger:consume pull N messages per iteration; in Laravel pass it through to Worker::run(['fetch-size' => 10]) and flush per batch rather than per message.
Pin the patch level. Several of these bugs bite exactly the way a Laravel dev would use them:
>=7.4.8.ack()/reject() crashing when the stream entry was already deleted (e.g. trimmed by stream_max_entries) — fixed in 7.4.15 / 8.0.15 / 8.1.2. Pin >=7.4.15.TransportMessageIdStamp missing — 6.4.18/7.1.11/7.2.3. Without the stamp you cannot correlate an envelope back to a Redis message ID, which breaks any custom ack/delete logic.Message ID stamp is not optional in your own code. $envelope->last(TransportMessageIdStamp::class) is the only handle on the underlying Redis entry. Log it on failure and you can XCLAIM/XDEL by hand during an incident.
Consumer names must be unique per process. The default is derived from host/PID and is fine on a single box, but on containers with PID 1 you'll get collisions across replicas — two consumers sharing a name means one steals the other's pending entries. Always set it from the hostname.
auto_setup=false is a footgun. The consumer group must exist before the first XREADGROUP; auto_setup uses XGROUP CREATE … MKSTREAM with $ as the ID, so any message published before the group was created is invisible. If you toggle auto_setup off in prod, create the group in a deploy step, and prefer 0 over $ if you need a backfill.
delete_after_ack defaults to false. The stream grows forever. Either set delete_after_ack=1, or set stream_max_entries (note it's approximate — MAXLEN ~ — so real length floats above the threshold), or both. Then remember gotcha #2: acking an entry that got trimmed used to crash.
redeliver_timeout (3600s) + claim_interval (60s) explain "stuck messages." A worker killed mid-handler leaves the entry in the PEL. It becomes reclaimable only after redeliver_timeout seconds, then XAUTOCLAIM runs at most every claim_interval. For fast-moving queues, drop both substantially — but not below your p99 handler time, or you'll double-process. This is at-least-once, so handlers must be idempotent; there is no exactly-once and no RedeliverUnacknowledgedMessages equivalent.
Long handlers go stale. The bridge implements KeepaliveReceiverInterface (7.3+); the Worker calls keepalive() automatically when the transport supports it. If you've written your own loop, call it yourself, or a 10-minute job will get its message claimed and redelivered while still running.
Serialization will bite you. Native PHP serialization is the default, and it will happily attempt to serialize an Eloquent model or fail outright on a closure. Always carry scalar IDs or a small readonly DTO across the boundary, and rehydrate inside the handler. Also set 'allowed_classes' => false (or an explicit allowlist) on deserialization for anything fed by another service.
No transactions, no outbox. $transport->send() inside a DB::transaction() still sends on rollback. Do what you'd do with any broker: dispatch after commit (DB::afterCommit()), or write to an outbox table and relay.
Redis Cluster is hostile to Streams. All keys for a stream + group must hash to one slot; a multi-key XREAD across slotted streams fails. 8.1 added a force-cluster option, but for Laravel day-to-day you're on a single-node/pooled Redis or Sentinel. Sentinel auth/SSL had a run of bugs (7.3.4 legacy option mapping, 7.4.6 SSL forwarding and auth alignment) — if you're on Sentinel, be on 7.4.6+.
Tooling gaps are real. Horizon doesn't see these queues. Telescope's job panel won't either. queue:failed/job:failed events don't fire. Your supervisor + XPENDING dashboard is the entire observability story unless you build listeners for the Worker's WorkerRunningEvent/WorkerStoppedEvent.
Version-lock messenger and the bridge together. A symfony/redis-messenger:^7.4 against a transitively-resolved symfony/messenger:6.4 produces confusing interface errors. Commit both to composer.json at the same minor and run composer why symfony/messenger when things look wrong.
How can I help you explore Laravel packages today?