Laravel

Laravel queue pub/sub with clue/redis-react: a practical guide

K

Kolawole

Mar 24, 2026

4 days ago 568 words 3 min read
Polling-based Redis queues work until they do not. When you need true event-driven job dispatch without a persistent worker process watching a list, clue/redis-react gives you real pub/sub in PHP. Here is how the architecture works and how to implement it in a production Laravel app.

The standard Laravel queue setup is straightforward: jobs go into a Redis list, workers poll that list, and work gets done.

For most applications, that is fine. The polling interval is short enough that latency is acceptable, and the simplicity is worth a lot.

But there are situations where polling is the wrong model.

If you need a process to react immediately when a specific event happens — not within a second or two, but immediately — or if you are building an architecture where components communicate through channels rather than shared lists, you want pub/sub, not polling.

This is a practical guide to replacing the polling model with genuine Redis pub/sub in a Laravel application using clue/react-redis.

When polling is the wrong model

Polling works by checking repeatedly: "is there anything for me to do?" That check has a cost even when the answer is no, and there is always a window between checks where something has happened but no work has started.

For most queue use cases, that window does not matter. A job that executes within two seconds of dispatch is fast enough.

It starts to matter when:

  • you are building real-time features where latency between events is visible to the user
  • you have many workers and the polling overhead is measurable
  • you want to move toward an event-driven architecture where services subscribe to channels rather than polling shared lists
  • you are replacing a system like Ratchet or a WebSocket server and want components to communicate through Redis

In these cases, the polling model imposes an architectural ceiling.

What clue/react-redis gives you

clue/react-redis is an async Redis client built on ReactPHP. It lets you write event-loop-driven PHP that can subscribe to Redis channels and react to messages without polling.

The key difference from the standard Redis client is that clue/react-redis does not block. It registers callbacks and processes messages as they arrive, using the ReactPHP event loop to handle I/O asynchronously.

This means a single PHP process can hold a subscription open indefinitely, react to published messages in real time, and dispatch or process work without the overhead of constant polling.

Installing the dependencies

composer require clue/redis-react react/event-loop

You will also need a working Redis connection. The rest of the Laravel stack — models, jobs, services — does not change.

The architecture

The pattern that works well in production has three parts:

Publishers — any part of your Laravel application that emits events. This can be a controller, a service class, a queued job, or a scheduled command. Publishing is a single Redis PUBLISH call.

A subscriber process — a long-running PHP process built on the ReactPHP event loop that subscribes to one or more channels and processes incoming messages. This runs alongside your normal queue workers.

Channel conventions — a consistent naming scheme for channels so publishers and subscribers stay decoupled. Something like app:orders:created or app:users:{id}:notifications works well.

Publishing from Laravel

Publishing is the simple part. Anywhere in your application:

use Illuminate\Support\Facades\Redis;

Redis::publish('app:orders:created', json_encode([
    'order_id' => $order->id,
    'user_id'  => $order->user_id,
    'total'    => $order->total,
]));

That is it. The publisher does not know or care who is subscribed. It emits the event and moves on.

Writing the subscriber process

The subscriber is a console command that runs the ReactPHP event loop:

<?php

namespace App\Console\Commands;

use Clue\React\Redis\Factory;
use Illuminate\Console\Command;
use React\EventLoop\Loop;

class RedisSubscriber extends Command
{
    protected $signature   = 'queue:redis-subscribe';
    protected $description = 'Subscribe to Redis pub/sub channels';

    public function handle(): void
    {
        $loop    = Loop::get();
        $factory = new Factory($loop);

        $factory->createClient(config('database.redis.default.url'))->then(
            function ($client) {
                $this->info('Subscribed to channels.');

                $client->subscribe('app:orders:created');

                $client->on('message', function (string $channel, string $payload) {
                    $data = json_decode($payload, true);

                    match ($channel) {
                        'app:orders:created' => $this->handleOrderCreated($data),
                        default              => null,
                    };
                });

                $client->on('close', function () {
                    $this->warn('Redis connection closed.');
                    Loop::stop();
                });
            },
            function (\Exception $e) {
                $this->error('Connection failed: ' . $e->getMessage());
                Loop::stop();
            }
        );

        $loop->run();
    }

    private function handleOrderCreated(array $data): void
    {
        // Dispatch a Laravel job, update a record, trigger a notification
        \App\Jobs\ProcessNewOrder::dispatch($data['order_id']);
    }
}

In Laravel 11 and 12, commands are auto-discovered — there is no Kernel.php to register them in. As long as your command lives in app/Console/Commands/, it is available immediately. Run it with Supervisor so it restarts on failure:

[program:redis-subscriber]
command=php /var/www/html/artisan queue:redis-subscribe
autostart=true
autorestart=true
stderr_logfile=/var/log/redis-subscriber.err.log
stdout_logfile=/var/log/redis-subscriber.out.log

If you need to register commands explicitly for any reason — a package command, or a command outside the default path — you do it in bootstrap/app.php using withCommands():

->withCommands([
    App\Console\Commands\RedisSubscriber::class,
])

That is the only registration pattern you need in modern Laravel.

Handling multiple channels

For more than one channel, subscribe to each before the event loop starts:

$client->subscribe('app:orders:created');
$client->subscribe('app:orders:updated');
$client->subscribe('app:payments:confirmed');

The message callback receives the channel name alongside the payload, so your dispatch logic can branch by channel.

How this fits alongside Horizon

clue/react-redis and Laravel Horizon are not in conflict — they solve different problems.

Horizon manages your standard queue workers: the processes that pull jobs off Redis lists, run them, and report back. It gives you a dashboard, retry controls, and worker supervision for that polling-based flow.

The pub/sub subscriber is a separate long-running process that does not touch the queue at all. It holds a Redis subscription open and reacts to published messages. Horizon does not know it exists, and it does not need to.

The clean arrangement is:

  • Horizon supervises your queue workers for background jobs
  • Supervisor manages the pub/sub subscriber process independently
  • Both use Redis, but for different primitives — lists vs channels

You can run both in the same application without any conflict.

When to use this and when not to

Use pub/sub when:

  • you need real-time reaction to events across decoupled components
  • you are building a system where services should not share a job queue
  • latency between event and processing is user-visible

Keep the standard polling queue when:

  • jobs can tolerate a second or two of dispatch latency
  • you want the simplicity of php artisan queue:work
  • you do not need cross-service event routing

Most production systems use both. Standard queues for background jobs where latency does not matter. Pub/sub for the narrow set of events where it does.

What this replaced in production

On MarketingBlocks, this pattern replaced a polling-based approach that was creating measurable overhead at scale. The subscriber process reacts to channel messages immediately instead of checking a list every second.

The result is a cleaner architecture: publishers emit events without coupling to consumers, consumers subscribe to only what they need, and Redis is doing the job it is genuinely good at.

That is what good queue architecture should feel like.

Share this post:

Comments (0)

No comments yet

Be the first to share your thoughts!

Leave a Comment

0 / 2000

Please be respectful and constructive in your comments.