Scaling Laravel with Redis: Caching, Queues, and Horizon in Production - NextGenBeing
Back to discoveries

Scaling Laravel with Redis: Caching, Queues, and Horizon in Production

Scale Laravel with Redis: a measured cache stampede, queue timeout rules, Horizon config, and the shared-Redis eviction trap that silently deletes jobs.

Web Development 9 min read
Bekzod Erkinov

Bekzod Erkinov

Oct 28, 2026 • 1 views
Scaling Laravel with Redis: Caching, Queues, and Horizon in Production
Photo by Zach M on Unsplash
Size:
Height:
9 min read 3,607 words Focus mode: Eye care:

Listen to Article

Loading...
0:00 / 0:00
0:00 0:00
Low High
0% 100%
⏸ Paused ▶️ Now playing... Ready to play ✓ Finished
Table of contents · 8 sections

Scaling Laravel with Redis: Caching, Queues, and Horizon in Production

Moving Laravel's cache and queues onto Redis is the standard fix when the database starts sweating, but one misconfigured Redis instance can quietly throw away your queued jobs. This post covers the Laravel Redis cache, queues and Horizon, and shows two things I ran in Docker that most guides skip: a cache stampede count and the eviction trap on a shared Redis. Sessions are out of scope: they use the same Redis connection settings, and I didn't test anything session-specific.

Affiliate disclosure: nextgenbeing.com may earn commissions from some links on this site. This post contains no partner links; every tool below is linked to its official site or docs.

Quick verdict

Pick this If
Redis cache + Cache::lock on hot keys A few expensive queries or reports get recomputed by many requests at once
Redis queues with plain queue:work You have one or two queues and a small, predictable job mix
Redis queues with Horizon You have 3+ queues and need per-queue scaling, wait-time alerts, or job metrics
Two Redis instances (cache vs. queue) Cache and queue share a server and the eviction policy is anything other than noeviction
Skip Horizon You run Redis Cluster (the Horizon docs say it isn't compatible), or one worker process handles your load

How I tested

Everything below with numbers was run on 2026-10-28 in throwaway Docker containers: redis:7-alpine (server v7.4.11) and the composer:2 image (PHP 8.5.11) with illuminate/cache v13.39.0, illuminate/redis and Predis 3.x. That's the real Laravel cache and Redis components, not a full Laravel app.

Docs: I read the Horizon, cache and queues pages on 13.x. Only the Redis page is cited at 12.x (phpredis recommendation, default cache connection on DB 1); I did not re-check it against 13.x.

Laravel Redis cache setup: the stampede nobody measures

Laravel defaults to the database cache driver, so pointing it at Redis is the easy first win. In .env:

CACHE_STORE=redis
QUEUE_CONNECTION=redis
REDIS_QUEUE_CONNECTION=default

(REDIS_QUEUE_CONNECTION selects which Redis connection the queue uses; default is the value in the 13.x queue config.) The harder problem is what happens when a hot key expires. Cache::remember doesn't coordinate: every request that misses runs the closure.

I simulated 20 simultaneous requests hitting an empty cache, with a closure that sleeps 300 ms and increments a counter each time it runs. This is the complete script (stampede.php), with the Predis connection set up. The lock branch only catches LockTimeoutException and counts it, so any other error surfaces instead of being swallowed:

<?php
require __DIR__.'/vendor/autoload.php';
use Illuminate\Cache\{RedisStore, Repository};
use Illuminate\Redis\RedisManager;
use Illuminate\Container\Container;

$redis = new RedisManager(new Container, 'predis',
    ['default' => ['host' => getenv('REDIS_HOST'), 'port' => 6379, 'database' => 0]]);
$cache = new Repository(new RedisStore($redis, 'c:'));
$key = 'report';
$compute = function () use ($redis) { $redis->connection()->incr('computes'); usleep(300000); return 'data'; };

if ($argv[1] === 'remember') {
    $cache->remember($key, 60, $compute);          // every miss recomputes
} else {                                           // one computes, others wait, then re-check
    if ($cache->get($key) === null) {
        try {
            $cache->lock('lock:'.$key, 10)->block(5, function () use ($cache, $key, $compute) {
                $cache->get($key) ?? $cache->put($key, $compute(), 60);
            });
        } catch (Illuminate\Contracts\Cache\LockTimeoutException $e) {
            $redis->connection()->incr('lock_timeouts');
        }
    }
}

composer.json requires illuminate/cache, illuminate/redis, illuminate/container (all ^13.39) and predis/predis (^3.7). Each run uses a fresh redis:7-alpine and launches 20 copies of the script from one shell loop with &, then wait. Full runner (run.sh, Git Bash on Windows; set MSYS_NO_PATHCONV=1 there so -w /app isn't rewritten):

docker network create ln
docker run -d --name rr --network ln redis:7-alpine
for mode in remember lock; do
  docker exec rr redis-cli FLUSHALL
  docker run --rm -v "$(pwd -W)":/app -w /app --network ln -e REDIS_HOST=rr composer:2 \
    sh -c "for i in \$(seq 1 20); do php stampede.php $mode & done; wait"
  echo "$mode -> computes = $(docker exec rr redis-cli GET computes), lock timeouts = $(docker exec rr redis-cli GET lock_timeouts)"
done
docker rm -f rr; docker network rm ln

Three runs of 20 concurrent processes each (output of the re-run for this post; an empty lock timeouts means that key was never set, i.e. zero timeouts):

remember: 20 concurrent cold-cache requests -> expensive computes = 15, lock timeouts =
lock: 20 concurrent cold-cache requests -> expensive computes = 1, lock timeouts =
remember: 20 concurrent cold-cache requests -> expensive computes = 15, lock timeouts =
lock: 20 concurrent cold-cache requests -> expensive computes = 1, lock timeouts =
remember: 20 concurrent cold-cache requests -> expensive computes = 18, lock timeouts =
lock: 20 concurrent cold-cache requests -> expensive computes = 1, lock timeouts =
Strategy Expensive computes (run 1 / 2 / 3)
Cache::remember 15 / 15 / 18
Cache::lock + re-check 1 / 1 / 1

Read these as illustrations, not benchmarks. The remember counts depend on how far apart the processes happen to start: later ones find a warm cache, which is why the count isn't 20 and changes between runs (earlier runs of the same script on this machine gave 17 / 15 / 18 and 17 / 12 / 14). Processes were started by a shell loop, not by a web server, and not inside a full Laravel app. My first attempt showed 2 for the lock variant; the bug was in my script, where each process called forget() at start and wiped a value another process had just written.

Two other tools from the same docs page:

  • Cache::flexible('key', [5, 10], fn () => ...) serves stale data during the second window and refreshes it after the response is sent, via a deferred function. I did not run it because that needs a full app.
  • Cache::memo() keeps resolved values in memory for the request or job, so repeated get calls on one key don't hit the cache again.

One gotcha, in the docs' words: "Flushing the cache does not respect your configured cache 'prefix' and will remove all entries from the cache." If cache and queue share a Redis database, cache:clear in a deploy script can delete pending jobs. The 12.x Redis docs show the default cache connection on DB 1 and default on DB 0; keep that separation.

More on query-level caching in our post on indexing and caching at 100M queries a day.

Laravel Redis queue configuration: retry_after, timeouts and duplicate jobs

The 13.x queue docs show the Redis connection with 'retry_after' => env('REDIS_QUEUE_RETRY_AFTER', 90), 'block_for' => null and 'after_commit' => false. The rule from the Horizon docs: the timeout "should always be at least a few seconds shorter than the retry_after value," otherwise "your jobs may be processed twice." The same section says to keep the Horizon timeout greater than any job-level timeout.

Practical defaults:

  • Set $timeout per job class, keep retry_after above your slowest job, and put Horizon's timeout between the two.
  • Set after_commit => true if you dispatch inside transactions, so a worker can't pick up a job before the row it needs is committed.
  • WithoutOverlapping and RateLimited consume attempts (13.x queue docs: releasing the job "will still increment the job's total number of attempts"). Per the Horizon docs, if you don't set tries, Horizon defaults to a single attempt (unless the job defines $tries), so set it.
  • ShouldBeUnique needs a cache driver with atomic locks (the 13.x queue docs list redis among the drivers that support atomic locks).

Redis queues vs database queues

Pros Cons
Redis Horizon works only on it; Redis-backed atomic locks for ShouldBeUnique One more service to run and watch; on a shared instance an eviction policy can delete jobs (see below)
Database No extra service No Horizon (it requires the redis queue connection)

For a notification-heavy example, see real-time notifications with Laravel and Pusher.

Laravel Horizon vs queue:work

Horizon is first-party and Redis-only. It requires the queue connection to be redis, isn't compatible with Redis Cluster, and reserves a Redis connection named horizon. Plain queue:work has none of those limits but gives you no dashboard, autoscaling or wait-time alerts.

Horizon pros and cons

Pros Cons
Dashboard, wait-time alerts (waits), metrics via horizon:snapshot, per-supervisor process caps Redis-only, no Redis Cluster; you also run Supervisor (or use Laravel Cloud), and must set stopwaitsecs and call horizon:terminate on deploy
Setting What it does Watch out for
balance => auto (default) Moves workers between queues by load, within minProcesses/maxProcesses Does not enforce priority; queue order is ignored
autoScalingStrategy time, size or log Assign workers by estimated time to clear, job count, or log of job count
balance => simple Fixed processes, split evenly across queues No autoscaling
balance => false Strict queue order like queue:work, still scales Process total only (minProcesses/maxProcesses)
Multiple supervisors Per-queue caps, e.g. images limited to one process The docs' way to keep heavy jobs from eating CPU

A supervisor block assembled from the 13.x docs examples (config/horizon.php). The values are the docs' sample values, not tuned recommendations; timeout 60 sits below the default retry_after of 90:

'environments' => [
    'production' => [
        'supervisor-1' => [
            'connection' => 'redis',
            'queue' => ['default', 'notifications'],
            'balance' => 'auto',
            'autoScalingStrategy' => 'time',
            'minProcesses' => 1,
            'maxProcesses' => 10,
            'balanceMaxShift' => 1,
            'balanceCooldown' => 3,
            'tries' => 10,
            'timeout' => 60,
        ],
        'supervisor-2' => [            // cap a heavy queue
            'queue' => ['images'],
            'minProcesses' => 1,
            'maxProcesses' => 1,
        ],
    ],
],

Supervisor config, from the same docs page (/etc/supervisor/conf.d/horizon.conf):

[program:horizon]
process_name=%(program_name)s
command=php /home/forge/example.com/artisan horizon
autostart=true
autorestart=true
user=forge
redirect_stderr=true
stdout_logfile=/home/forge/example.com/horizon.log
stopwaitsecs=3600

Per the docs, stopwaitsecs must be greater than your longest-running job, or Supervisor may kill it mid-run. Then:

sudo supervisorctl reread && sudo supervisorctl update && sudo supervisorctl start horizon

Other production steps from the docs:

  1. Run php artisan horizon:terminate in your deploy so Supervisor restarts Horizon with new code.
  2. Schedule horizon:snapshot every five minutes (Schedule::command('horizon:snapshot')->everyFiveMinutes();), or the metrics dashboard stays empty.
  3. Tune waits; undefined queues default to a 60-second long-wait threshold.
  4. Check the viewHorizon gate; outside local it controls who can see the dashboard.
  5. While the app is in maintenance mode, Horizon won't process jobs unless the supervisor sets 'force' => true.

The Horizon docs point to Laravel Cloud if you'd rather not manage Supervisor. I haven't tested it.

Two more caveats:

  • Redis Cluster: Horizon doesn't support it. For plain queue:work on a cluster, the 13.x queue docs say queue names must contain a key hash tag (the default {default} has one).
  • Client choice: the cache docs accept either phpredis or Predis, and the 12.x Redis docs recommend phpredis. I used Predis for convenience and took no throughput or latency measurements, so nothing here compares the two clients.

Laravel Redis eviction: the shared-Redis trap I reproduced

The Redis docs say that because of the Pareto principle, allkeys-lru "is a good default option" for a cache. That's fine for a cache, but dangerous on a server that also holds your queue: allkeys-lru can evict any key, including the list of pending jobs.

Reproduction (evict.sh, complete). The server starts with maxmemory 3mb and allkeys-lru, 200 jobs go into a list, then 6,000 cache keys of 800 bytes each are written. The same container is then switched to noeviction, flushed, and the test repeats:

set -e
docker rm -f r1 >/dev/null 2>&1 || true
docker run -d --name r1 redis:7-alpine redis-server --maxmemory 3mb --maxmemory-policy allkeys-lru >/dev/null
sleep 1
docker exec r1 redis-server --version
for i in $(seq 1 200); do echo "RPUSH queues:default job$i"; done | docker exec -i r1 redis-cli >/dev/null
echo "queue length before cache fill: $(docker exec r1 redis-cli LLEN queues:default)"
docker exec r1 sh -c 'for i in $(seq 1 6000); do echo "SET cache:k$i $(head -c 800 /dev/zero | tr "\0" x)"; done | redis-cli >/dev/null'
echo "queue length after cache fill (allkeys-lru): $(docker exec r1 redis-cli LLEN queues:default)"
echo "evicted_keys: $(docker exec r1 redis-cli INFO stats | grep evicted_keys)"
docker exec r1 redis-cli CONFIG SET maxmemory-policy noeviction >/dev/null
docker exec r1 redis-cli FLUSHALL >/dev/null
for i in $(seq 1 200); do echo "RPUSH queues:default job$i"; done | docker exec -i r1 redis-cli >/dev/null
docker exec r1 sh -c 'for i in $(seq 1 6000); do echo "SET cache:k$i $(head -c 800 /dev/zero | tr "\0" x)"; done | redis-cli 2>&1 | sort | uniq -c | head -3'
echo "queue length (noeviction): $(docker exec r1 redis-cli LLEN queues:default)"
docker rm -f r1 >/dev/null

Output from the re-run for this post (the last three counted lines are uniq -c output: 3,908 blank lines, 2,092 OK, 3,908 OOM errors):

Redis server v=7.4.11 sha=00000000:0 malloc=jemalloc-5.3.0 bits=64 build=40ff01a501d8e4b6
queue length before cache fill: 200
queue length after cache fill (allkeys-lru): 0
evicted_keys: evicted_keys:3846
   3908
   2092 OK
   3908 OOM command not allowed when used memory > 'maxmemory'.
queue length (noeviction): 200
Policy Queue length after cache fill Result
allkeys-lru 0 (from 200) 3,846 keys evicted; every pending job gone, no error
noeviction 200 3,908 of 6,000 cache writes failed with OOM command not allowed

On a shared instance you pick between silent job loss and cache writes that error out. The 3 MB limit is only there to make the effect fast; I didn't test a production-sized instance. The architectural fix is separate Redis instances: maxmemory with allkeys-lru for the cache, noeviction plus headroom and a memory alert for the queue. The Redis docs say of the volatile-* policies that they're mainly useful for one instance holding both cache and persistent keys, "However, you should consider running two separate Redis instances in a case like this, if possible." And volatile-lru only protects keys without a TTL, so it only helps if every cache entry has one; per the docs, the volatile-* policies behave like noeviction when no keys have an expiry.

Who should do what

Move to Redis now if you're on the database cache or queue driver and see lock contention or slow polling of the jobs table. Use two instances if you can.

Add Horizon when you have more than a couple of queues, want wait-time alerts, or keep hand-tuning --queue=a,b worker flags.

Skip Horizon if you need Redis Cluster, or one queue:work process covers your load.

Next steps checklist

  • Cache and queue on separate Redis instances (or at least noeviction for the queue)
  • CACHE_STORE=redis and QUEUE_CONNECTION=redis set, retry_after above your slowest job
  • Horizon timeout below retry_after; tries set explicitly
  • horizon:snapshot scheduled every five minutes
  • Supervisor stopwaitsecs greater than your longest job; horizon:terminate in the deploy
  • No cache:clear against a Redis database that holds queued jobs

The non-Redis side of capacity planning is covered in our 100,000-user scaling write-up.

Bekzod Erkinov

Bekzod Erkinov

Author

Founder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.

Free guide

Get the AI-Assisted Developer's Field Guide

The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.

Comments (0)

Please log in to leave a comment.

Log In

Related Articles

Don't miss the next deep dive

Get one well-researched tutorial in your inbox each week. No spam, unsubscribe anytime.