Bekzod Erkinov
Listen to Article
Loading...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 repeatedgetcalls 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
$timeoutper job class, keepretry_afterabove your slowest job, and put Horizon'stimeoutbetween the two. - Set
after_commit => trueif you dispatch inside transactions, so a worker can't pick up a job before the row it needs is committed. WithoutOverlappingandRateLimitedconsume 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 settries, Horizon defaults to a single attempt (unless the job defines$tries), so set it.ShouldBeUniqueneeds a cache driver with atomic locks (the 13.x queue docs listredisamong 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:
- Run
php artisan horizon:terminatein your deploy so Supervisor restarts Horizon with new code. - Schedule
horizon:snapshotevery five minutes (Schedule::command('horizon:snapshot')->everyFiveMinutes();), or the metrics dashboard stays empty. - Tune
waits; undefined queues default to a 60-second long-wait threshold. - Check the
viewHorizongate; outsidelocalit controls who can see the dashboard. - 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:workon 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
noevictionfor the queue) -
CACHE_STORE=redisandQUEUE_CONNECTION=redisset,retry_afterabove your slowest job - Horizon
timeoutbelowretry_after;triesset explicitly -
horizon:snapshotscheduled every five minutes - Supervisor
stopwaitsecsgreater than your longest job;horizon:terminatein the deploy - No
cache:clearagainst a Redis database that holds queued jobs
The non-Redis side of capacity planning is covered in our 100,000-user scaling write-up.
Keep reading
Mastering Multi-Tenant SaaS Architecture Patterns for Enterprise Applications
27 min · 911 views
Web Development10 Essential Tools for Modern Frontend Development
16 min · 853 views
Web DevelopmentGraceful Shutdown Patterns for Laravel Queue Workers in Production
21 min · 282 views
Bekzod Erkinov
AuthorFounder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.
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