Bekzod Erkinov
Listen to Article
Loading...Table of contents · 8 sections
Laravel Horizontal Scaling with Redis: Caching, Query Optimization and an HPA
Production-Grade E-Commerce with Laravel 12, Part 6
You want to run more than one copy of a Laravel 12 app without logins vanishing, caches disagreeing, or one expired key flattening your database. By the end you'll have a stateless app on Redis, a slow query fixed with EXPLAIN, a cache that survives 50 concurrent requests, and an HPA scaling pods on a local kind cluster, including the one load-test mistake that makes the extra pods useless.
Parts 1 to 5 built the shop and containerised it; none of that is needed here. If you are new to the Redis side, Redis vs Memcached in 2026 and Redis Sentinel vs Cluster cover the store choice, and graceful shutdown for Laravel queue workers matters as soon as an HPA starts killing pods. Every number below comes from one Windows laptop (8 CPUs given to Docker Desktop, everything sharing them): single run unless a range is shown, relative only.
Minimal setup
A fresh Laravel 12 app, predis for Redis, MySQL 8.4, Redis 7, and two php-fpm (PHP 8.4) replicas behind nginx. The Kubernetes part uses kind and metrics-server. The Laravel 12 docs ask for predis ~2.0 or phpredis; I ran predis 3.x and did not test 2.x or phpredis.
composer create-project laravel/laravel:^12.0 shop && cd shop
composer require predis/predis
docker network create p6net
docker run -d --name p6-mysql --network p6net -e MYSQL_ROOT_PASSWORD=secret -e MYSQL_DATABASE=shop mysql:8.4
docker run -d --name p6-redis --network p6net redis:7-alpine
p6-nginx (used by every load test below) is the nginx container from the config further down, started on the same network with docker run -d --name p6-nginx --network p6net -p 8088:80 -v $PWD/conf.d:/etc/nginx/conf.d nginx:alpine (the nginx.conf with worker_processes 1 replaces the image's default).
.env points the app at those containers and moves all three kinds of state into Redis:
DB_CONNECTION=mysql
DB_HOST=p6-mysql
DB_DATABASE=shop
DB_USERNAME=root
DB_PASSWORD=secret
REDIS_CLIENT=predis
REDIS_HOST=p6-redis
SESSION_DRIVER=redis
CACHE_STORE=redis
QUEUE_CONNECTION=redis
The data is two tables, products (id, category_id, name, price_cents, status, timestamps) and orders (id, user_id, product_id, total_cents, created_at), filled with a recursive CTE inside the migration. Here is the seeding part, which gives 200,000 products in 50 categories and 500,000 orders over 20,000 users:
DB::statement('SET SESSION cte_max_recursion_depth = 1000000');
DB::statement("INSERT INTO products (category_id, name, price_cents, status, created_at, updated_at)
WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM n WHERE i < 200000)
SELECT 1 + (i % 50), CONCAT('Product ', i), 500 + (i * 37 % 20000), IF(i % 10 = 0, 'archived', 'active'), NOW(), NOW() FROM n");
DB::statement("INSERT INTO orders (user_id, product_id, total_cents, created_at)
WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM n WHERE i < 500000)
SELECT 1 + (i * 7919 % 20000), 1 + (i * 104729 % 200000), 500 + (i * 53 % 30000),
NOW() - INTERVAL (i % 365) DAY - INTERVAL (i % 86400) SECOND FROM n");
The image is php:8.4-fpm plus pdo_mysql and opcache, with the app copied in and a static pool. p6-app is the app directory packaged as an image (FROM scratch + COPY . /app). Build it first from the app root with docker build -t p6-app ., then the fpm image below with docker build -t p6-fpm:lab .:
FROM php:8.4-fpm
RUN docker-php-ext-install pdo_mysql opcache
COPY --from=p6-app /app /app
RUN printf '[www]\npm = static\npm.max_children = 8\nlisten = 9000\nclear_env = no\n' > /usr/local/etc/php-fpm.d/zz-pool.conf \
&& chown -R www-data:www-data /app/storage /app/bootstrap/cache
WORKDIR /app
Two containers from that image (p6-app1, p6-app2, started with php-fpm -F and --hostname app1 / app2) sit behind nginx:
# nginx.conf, main context. Demo-only: one worker gives a single sequential
# client a strict app1/app2 alternation.
worker_processes 1;
# conf.d/default.conf
upstream fpm { server p6-app1:9000; server p6-app2:9000; }
server {
listen 80;
location / {
include fastcgi_params;
fastcgi_pass fpm;
fastcgi_param SCRIPT_FILENAME /app/public/index.php;
fastcgi_param SCRIPT_NAME /index.php;
}
}
All the routes the benchmarks use live in one file. /whoami tells you which replica answered; expensiveReport() is defined in the stampede section below:
// routes/web.php
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;
function categoryPage($id) {
return DB::table('products')->where('category_id', $id)->where('status', 'active')
->orderBy('price_cents')->limit(24)->get();
}
Route::get('/whoami', function (Request $r) {
$r->session()->increment('hits');
return ['pod' => gethostname(), 'hits' => $r->session()->get('hits'), 'session_driver' => config('session.driver')];
});
Route::get('/users/{id}/orders', fn ($id) => DB::table('orders')->where('user_id', $id)->orderByDesc('created_at')->limit(20)->get());
Route::get('/category/{id}', fn ($id) => categoryPage($id));
Route::get('/cached/category/{id}', fn ($id) => Cache::tags(['products', "category:$id"])
->remember("category:$id:page1", 600, fn () => categoryPage($id)));
Route::get('/report/naive', fn () => Cache::remember('report:naive', 5, fn () => expensiveReport()));
Route::get('/report/flexible', fn () => Cache::flexible('report:flex', [5, 30], fn () => expensiveReport()));
Route::get('/report/lock', function () {
if (($v = Cache::get('report:lock')) !== null) {
return $v;
}
try {
return Cache::lock('report:lock:build', 30)->block(10, function () {
return Cache::get('report:lock')
?? tap(expensiveReport(), fn ($r) => Cache::put('report:lock', $r, 5));
});
} catch (LockTimeoutException) {
return Cache::get('report:lock') ?? abort(503); // stale-ish value, or "try again"
}
});
php -l accepts this file. The benchmark numbers below were measured on these routes, except the lock rows (see the stampede section).
All load tests below use hey as a container on the same Docker network (docker run --rm --network p6net williamyeh/hey ...), aimed at the nginx container.
Laravel horizontal scaling: make the app stateless with Redis
Horizontal scaling only works if any replica can serve any request. Three things break that: sessions on local disk, a cache that isn't shared, and uploads written to the container's filesystem.
Here's the failure. I set SESSION_DRIVER=file on both replicas (-e SESSION_DRIVER=file) and sent six requests with one cookie jar through nginx (curl -s -b jar -c jar http://localhost:8088/whoami, six times). That is why the nginx config above sets worker_processes 1: with auto, each worker keeps its own round-robin position, and a single sequential client can stick to one replica:
== SESSION_DRIVER=file
{"pod":"app1","hits":1,"session_driver":"file"}
{"pod":"app2","hits":1,"session_driver":"file"}
{"pod":"app1","hits":2,"session_driver":"file"}
{"pod":"app2","hits":2,"session_driver":"file"}
{"pod":"app1","hits":3,"session_driver":"file"}
{"pod":"app2","hits":3,"session_driver":"file"}
Each replica keeps its own copy of the "same" session, so the counter runs twice. For a real login this means being logged in on one request and anonymous on the next. With SESSION_DRIVER=redis:
== SESSION_DRIVER=redis
{"pod":"app1","hits":1,"session_driver":"redis"}
{"pod":"app2","hits":2,"session_driver":"redis"}
{"pod":"app1","hits":3,"session_driver":"redis"}
{"pod":"app2","hits":4,"session_driver":"redis"}
{"pod":"app1","hits":5,"session_driver":"redis"}
{"pod":"app2","hits":6,"session_driver":"redis"}
Laravel's session docs list the drivers and note that database is the default; Redis needs either phpredis or predis/predis. If you want sessions on a different Redis connection than the cache, SESSION_CONNECTION selects it. Once Redis holds every login it is also a single point of failure, which is the Sentinel vs Cluster question.
Two gotchas worth knowing before production:
- Know your eviction policy. Run
redis-cli config get maxmemory-policyon your instance; the eviction docs don't state the default policy. The Redis eviction docs say that undernoevictionthe server "will return an error when you try to execute commands that cache new data" oncemaxmemoryis reached, while reads keep working. They also saymaxmemory0, meaning no limit, is the default on 64-bit systems, so this only bites once you configure a limit. If sessions and cache share one Redis, a full cache can break logins. Switching toallkeys-lrufixes that by evicting any key, including sessions. The docs suggest separate instances when you mix cache and keys you can't lose. I'd run two instances rather than pick one policy that's wrong for one of them. - Locks need one shared store. Laravel's atomic locks only work if every server talks to the same central cache server. A per-pod
fileorarraycache silently gives you one lock per pod, i.e. no lock.
The queue is the third piece of shared state: with QUEUE_CONNECTION=redis, any replica can enqueue and any worker can run the job. Workers get killed on every scale-down, so apply the graceful shutdown patterns before you put an HPA on them.
Files go to object storage (not run)
I didn't run this part (no bucket here). Per the filesystem docs, install league/flysystem-aws-s3-v3 "^3.0", set the AWS_* variables and FILESYSTEM_DISK=s3 so uploads stop landing on a pod's disk, and use Storage::temporaryUrl() for private files.
Fix the query before you cache it
Caching a slow query hides it until the key expires. Look at it first. EXPLAIN ANALYZE in MySQL 8.4 runs the statement and shows actual time and rows per step, which is what you want, not just the optimiser's guess.
The route is "a user's latest orders":
DB::table('orders')->where('user_id', $id)->orderByDesc('created_at')->limit(20)->get();
With no index on user_id (EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id=4242 ORDER BY created_at DESC LIMIT 20):
-> Limit: 20 row(s) (cost=50214 rows=20) (actual time=652..652 rows=20 loops=1)
-> Sort: orders.created_at DESC, limit input to 20 row(s) per chunk (cost=50214 rows=499334) (actual time=652..652 rows=20 loops=1)
-> Filter: (orders.user_id = 4242) (cost=50214 rows=499334) (actual time=18.1..652 rows=25 loops=1)
-> Table scan on orders (cost=50214 rows=499334) (actual time=2.67..546 rows=500000 loops=1)
It read all 500,000 rows to return 20. Add a composite index with the equality column first and the sort column second:
ALTER TABLE orders ADD INDEX orders_user_created (user_id, created_at);
-> Limit: 20 row(s) (cost=8.75 rows=20) (actual time=0.188..0.196 rows=20 loops=1)
-> Index lookup on orders using orders_user_created (user_id=4242) (reverse) (cost=8.75 rows=25) (actual time=0.187..0.192 rows=20 loops=1)
Plain EXPLAIN confirms there is no filesort:
id select_type table type possible_keys key key_len ref rows filtered Extra
1 SIMPLE orders ref orders_user_created orders_user_created 4 const 25 100.00 Backward index scan
That's about 650 ms down to about 0.2 ms (two runs each). Through nginx, docker run --rm --network p6net williamyeh/hey -z 10s -c 20 http://p6-nginx/users/4242/orders gave 7.3 requests/s (average 2.60 s) once without the index, then 80.9 and 78.3 requests/s (average about 0.25 s) in two runs with it.
The category page has a filter plus a sort: WHERE category_id = 7 AND status = 'active' ORDER BY price_cents LIMIT 24. Three index states, same query, 200,000 rows (two runs each, the range is shown):
| Index | Plan | Actual time |
|---|---|---|
| none | table scan of 200,000 rows, then sort | 182 to 234 ms |
(category_id) |
4,000 rows via index, filter, then sort | 30 to 51 ms |
(category_id, status, price_cents) |
index lookup, stops after 24 rows, no sort | 1.0 to 1.2 ms |
The middle row is the common trap: an index exists, EXPLAIN shows ref, and the query still sorts 4,000 rows every time. The composite index also needs the columns in filter-then-sort order for the scan to stop early.
Cache with tags, but check the driver
Once the query is indexed, here's the surprise: caching it barely helped here. hey -z 10s -c 20, composite index in place:
| Route | Requests/s | Avg latency |
|---|---|---|
/category/7 (indexed query, no cache) |
85.1 | 0.233 s |
/cached/category/7 (Redis, tagged) |
86.5 | 0.229 s |
I ran each route once, so I have no spread to put next to 85.1 versus 86.5; a 1.6% difference from single runs is not evidence of anything. The honest reading is "no improvement I could measure on this route", not "caching doesn't help". The ceiling here is the framework itself (middleware, session read/write over predis, php-fpm on a shared laptop), and one MySQL on the same box is nowhere near saturated. With many replicas all hitting one database, a cache still removes that load, and this single-box test cannot show it. My rule: index first, then cache only what is still expensive after the index (aggregates, joins over big ranges, calls to other services). A 1 ms query behind a 1-2 ms cache read isn't worth an invalidation problem.
When you do cache a list, tags let you flush one slice instead of the whole store:
Cache::tags(['products', "category:$id"])->remember("category:$id:page1", 600, fn () =>
DB::table('products')->where('category_id', $id)->where('status', 'active')->orderBy('price_cents')->limit(24)->get());
// on write:
Cache::tags(["category:{$p->category_id}"])->flush();
To check the flush only touches one category I ran this script with php artisan tinker tags.php:
Cache::flush();
Cache::tags(['products','category:7'])->put('category:7:page1', 'A', 600);
Cache::tags(['products','category:8'])->put('category:8:page1', 'B', 600);
echo "cat7 before: ", Cache::tags(['products','category:7'])->get('category:7:page1'), PHP_EOL;
Cache::tags(['category:7'])->flush();
var_dump(Cache::tags(['products','category:7'])->get('category:7:page1'));
echo "cat8 after flushing cat7: ", Cache::tags(['products','category:8'])->get('category:8:page1'), PHP_EOL;
try { Cache::store('database')->tags(['x'])->put('k','v',60); } catch (Throwable $e) { echo get_class($e), ': ', $e->getMessage(), PHP_EOL; }
cat7 before: A
NULL
cat8 after flushing cat7: B
BadMethodCallException: This cache store does not support tagging.
The last line is a limit: the cache docs say tags aren't supported on the file, dynamodb or database drivers. Laravel 12's default store is database, so a fresh app hits this. Two more details from the docs: you must pass the same ordered tags to read an item back, and Cache::flush() ignores your prefix and clears the entire store, including sessions if they share it.
Tags on Redis: cost
Tags are not free on Redis. From reading the 12.x source (RedisTagSet, RedisTaggedCache; I did not benchmark this): every tagged put also does a ZADD into a sorted set named tag:<name>:entries, one per tag, holding the key of every item written under that tag. That is extra memory per cached key per tag, and a flush() has to read all of a tag's entries and delete them in chunks of 1,000. A broad tag such as products over millions of keys makes that flush a long operation on a single-threaded Redis, and expired items leave stale entries in the set until they are pruned. So keep tags narrow (category:7, not one tag on everything), and time a flush on production-sized data before wiring it to a write path.
Laravel Redis cache stampede: lock vs flexible
A "stampede" is many requests missing the same expired key at once and all rebuilding it. To measure it I used an aggregate that takes 2.0 to 2.4 s on this data (revenue per category over 90 days) and counted how many times it really ran, with Cache::increment('computations') inside the closure. The two helpers, as I'd write them to reproduce this (the 2.0 to 2.4 s timing belongs to the query I used on this data):
function expensiveReport() {
Cache::increment('computations'); // counts real builds; read it with Cache::get('computations')
return DB::table('orders')->join('products', 'products.id', '=', 'orders.product_id')
->where('orders.created_at', '>=', now()->subDays(90))
->groupBy('products.category_id')->selectRaw('products.category_id, SUM(orders.total_cents) AS revenue')->get();
}
Three versions of the route, TTL 5 s (the lock variant follows as a full handler):
// naive
Cache::remember('report:naive', 5, fn () => expensiveReport());
// stale-while-revalidate: fresh for 5 s, servable stale for 30 s more
Cache::flexible('report:flex', [5, 30], fn () => expensiveReport());
The lock variant as a complete route handler. It needs use Illuminate\Contracts\Cache\LockTimeoutException;, and block() returns the closure's result, so the handler returns it directly:
Route::get('/report/lock', function () {
if (($v = Cache::get('report:lock')) !== null) {
return $v;
}
try {
return Cache::lock('report:lock:build', 30)->block(10, function () {
return Cache::get('report:lock')
?? tap(expensiveReport(), fn ($r) => Cache::put('report:lock', $r, 5));
});
} catch (LockTimeoutException) {
return Cache::get('report:lock') ?? abort(503); // stale-ish value, or "try again"
}
});
Why this reads the cache twice: the first Cache::get keeps the hot path to one Redis call with no lock traffic. The second one, inside the lock, matters because 49 requests can be queued on the lock while one builds; without the re-check each of them would rebuild in turn, and you'd have a serial stampede instead of a parallel one.
The pitfall: block(10) waits at most 10 s, then throws LockTimeoutException, which becomes a 500 for every waiter unless you catch it as above. The build must finish well inside the block timeout, and the lock TTL (30) must exceed the build time. I never made the timeout fire; the catch branch is from the docs.
The inputs for every stampede run: TTL 5 s, a report build of 2.0 to 2.4 s, and hey -z 40s -c 50 http://p6-nginx/report/<naive|lock|flexible> (50 connections for 40 s). I warmed the key with one request, reset the cache between runs, and read the counter before and after:
| Strategy | Real computations in 40 s | Avg latency | p99 | Slowest |
|---|---|---|---|---|
remember |
48 | 1.26 s | 9.9 s | 12.2 s |
lock + block (earlier variant, not the handler above) |
6 | 0.78 s | 3.5 s | 4.7 s |
flexible([5, 30]) |
6 | 0.47 s | 1.17 s | 1.45 s |
With a 5 s TTL, the value can expire at most once per 5 s, so a well-behaved cache rebuilds at most about 40 s / 5 s = 8 times (a build of 2+ s only makes that slower). remember did it 48 times because that estimate assumes an instant rebuild: each expiry opens a window of at least one build (2.0 to 2.4 s) in which every arriving request misses and runs the query itself, and 50 concurrent connections can fill that window with up to 50 builds, so 48 in 40 s is roughly one window's worth of connections rather than eight single rebuilds. The lock rows come from an earlier variant of the handler, so treat them as approximate.
The lock fixes the count but not the latency: the waiters sit in block(10) until the builder finishes, which is why the slowest request was 4.7 s. Cache::flexible fixes both: during the stale window it serves the old value immediately and refreshes after the response. The docs describe it as the stale-while-revalidate pattern. In the 12.69.3 source (Repository::flexible, which starts at line 561 in that tag) the stale-path refresh is wrapped in a lock:
$refresh = function () use ($key, $ttl, $callback, $lock, $created) {
$this->store->lock(
"illuminate:cache:flexible:lock:{$key}",
$lock['seconds'] ?? 0,
$lock['owner'] ?? null,
)->get(function () use ($key, $callback, $created, $ttl) {
if ($created !== $this->get("illuminate:cache:flexible:created:{$key}")) {
return; // someone already refreshed
}
...
So only the request that wins that lock recomputes during the stale window, which fits the 6 computations above.
The catch: flexible only protects you once a value exists. On a cold start I ran the same test with an empty cache (hey -z 20s -c 50), one run per row:
| Strategy | Real computations | Avg latency | Slowest |
|---|---|---|---|
remember |
32 | 2.54 s | 13.9 s |
lock + block (earlier variant) |
3 | 0.89 s | 4.3 s |
flexible([5, 30]) |
16 | 2.83 s | 17.0 s |
With nothing cached there is no stale value to serve. In the same source, the branch taken when the value or its created marker is missing (in_array(null, [$value, $created], true)) just calls $callback and stores the result, with no lock, so every request that arrives before the first store computes. That is what the source says; the exact counts (16, 32) are single runs and I never repeated them, so don't rank flexible against remember from this table. The only firm reading is lock at 3 versus tens for the other two. So: warm hot keys on deploy (a scheduled command or a curl loop in your release step), and use the lock pattern when a cold miss is possible and serving stale data isn't acceptable. My decision rule:
- Value can be a few seconds stale:
flexible, and pre-warm. - Must be fresh, rebuild is rare:
lock+blockwith a re-check inside the lock. - Cheap query (a few ms): plain
remember, don't bother.
Autoscale on a local kind cluster
Everything here ran on a single-node kind cluster. The fpm image was loaded with kind load docker-image p6-fpm:lab and the pod uses imagePullPolicy: Never. The HPA needs a metrics source, so I installed metrics-server with --kubelet-insecure-tls, which is for kind only. Until it is ready, the HPA reports <unknown> targets; that is not a bug in your config, just wait.
Redis and the shop Service come first: the load pod calls http://shop/... and the app finds Redis at redis. The nginx ConfigMap nginx-conf is the server block from above with fastcgi_pass 127.0.0.1:9000; and no upstream; create it with kubectl create configmap nginx-conf --from-file=default.conf. The /whoami route used in the HPA test only touches Redis (sessions), so MySQL isn't needed in the cluster.
apiVersion: apps/v1
kind: Deployment
metadata: {name: redis}
spec:
selector: {matchLabels: {app: redis}}
template:
metadata: {labels: {app: redis}}
spec:
containers:
- {name: redis, image: "redis:7-alpine", ports: [{containerPort: 6379}]}
---
apiVersion: v1
kind: Service
metadata: {name: redis}
spec:
selector: {app: redis}
ports: [{port: 6379}]
---
apiVersion: v1
kind: Service
metadata: {name: shop}
spec:
selector: {app: shop}
ports: [{port: 80, targetPort: 80}]
Then the Deployment and HPA (php-fpm and nginx as sidecars in one pod):
apiVersion: apps/v1
kind: Deployment
metadata: {name: shop}
spec:
# no "replicas:" on purpose - the HPA owns it
selector: {matchLabels: {app: shop}}
template:
metadata: {labels: {app: shop}}
spec:
enableServiceLinks: false # otherwise k8s injects REDIS_PORT=tcp://..., which Laravel reads
containers:
- name: fpm
image: p6-fpm:lab
imagePullPolicy: Never
command: ["php-fpm", "-F"]
env:
- {name: REDIS_HOST, value: redis}
- {name: SESSION_DRIVER, value: redis}
- {name: CACHE_STORE, value: redis}
resources:
requests: {cpu: 200m, memory: 128Mi}
limits: {cpu: 500m, memory: 256Mi}
- name: nginx
image: nginx:alpine
ports: [{containerPort: 80}]
readinessProbe: {httpGet: {path: /whoami, port: 80}, periodSeconds: 3}
volumeMounts: [{name: conf, mountPath: /etc/nginx/conf.d}]
volumes: [{name: conf, configMap: {name: nginx-conf}}]
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: {name: shop}
spec:
scaleTargetRef: {apiVersion: apps/v1, kind: Deployment, name: shop}
minReplicas: 1
maxReplicas: 4
metrics:
- type: Resource
resource: {name: cpu, target: {type: Utilization, averageUtilization: 50}}
behavior:
scaleDown: {stabilizationWindowSeconds: 60}
Three settings matter:
enableServiceLinks: false. Kubernetes injectsREDIS_PORT=tcp://10.x.x.x:6379for a Service namedredis, and Laravel's Redis config readsREDIS_PORTexpecting a number.- No
replicas:in the Deployment. The HPA docs section Migrating Deployments and StatefulSets to horizontal autoscaling recommends removingspec.replicas; otherwise applying the manifest (e.g.kubectl apply -f deployment.yaml) scales the pods back to that value, which can cause flapping. - CPU utilisation is relative to
requests. 50% of a 200m request is 100m per pod; no request, no metric. The same page's Tolerance section gives a default cluster-wide tolerance of 10% (kube-controller-manager's--horizontal-pod-autoscaler-tolerance), so small deviations from the target don't trigger scaling.
I shortened scaleDown to 60 s so the demo finishes; the default is 300 s, and I'd keep the default in production so you don't flap.
Run 1, with keep-alive. kubectl run hey --image=williamyeh/hey --restart=Never -- -z 200s -c 60 http://shop/whoami, a pod inside the cluster, 60 connections for 200 s. I sampled kubectl get hpa shop and kubectl top pods in a loop every few seconds from a second terminal (kubectl get hpa -w shows the same HPA columns); the lines below are reformatted from that script's output (time since I started the load pod, HPA CPU, replicas, then kubectl top pods per pod):
t+27s | cpu 9%/50% | replicas 1 | 361m
t+42s | cpu 144%/50% | replicas 1 | 519m
t+56s | cpu 207%/50% | replicas 3 | 517m 217m 101m
t+70s | cpu 207%/50% | replicas 4 | 147m 516m 34m 101m
t+99s | cpu 58%/50% | replicas 4 | 18m 516m 21m 21m
t+212s | cpu 56%/50% | replicas 4 | 12m 519m 13m 14m (4/4 ready)
t+241s | cpu 6%/50% | replicas 4 | 14m 25m 12m 14m (load finished)
t+284s | cpu 7%/50% | replicas 3 | scale-down begins
The HPA saw 144% at t+42 s, had 3 replicas by t+56 s and the maximum of 4 by t+70 s. But load never spread out. One pod sat at about 516m, its 500m CPU limit, for the whole run while the other three idled at 12 to 21m. The result was 15.8 requests/s, 3.76 s average, which is one throttled pod's throughput. The metric also stayed at 56 to 58%, above the 50% target, even at 4 replicas: an average dragged up by one pinned pod can't be lowered by adding more pods.
The cause is the client, not the HPA. Service load balancing happens per connection, and hey kept its 60 connections open from the moment only one pod existed. New pods get new connections only, and there were none. Pod startup added delay too: the fourth pod only reported ready around t+212 s (3/4 from t+56 s). I didn't chase the cause; measure your own startup time, because it is added to every scale-up.
Run 2, without keep-alive. The fix for the test is -disable-keepalive, so every request opens a new connection and can land on a new pod:
t+198s | cpu 202%/50% | replicas 4 | 379m 524m 527m 504m
t+209s | cpu 202%/50% | replicas 4 | 523m 483m 434m 506m (4/4 ready)
Now all four pods work at once, which Run 1 never achieved. I'm not quoting a throughput for this run: the scale-up came late (the HPA showed <unknown> for a while at the start and I did not capture kubectl describe hpa events, so I can't tell you why), which left the 200 s run too short to say anything about steady state. The lesson is only this: in production the equivalent of hey's keep-alive is an ingress or client that holds long-lived connections, so check connection reuse and limits before blaming the HPA.
Not tested
phpredis (I used predis), a multi-node cluster, object storage, a managed Redis, a read replica, queue workers under load, and LockTimeoutException actually firing.
What to do next
- Set
SESSION_DRIVER=redisandCACHE_STORE=redis, then run the/whoamicheck through your own balancer. If the counter doesn't climb by one per request, something is still local. - Check
redis-cli config get maxmemory-policyon your Redis. If sessions share it with the cache, decide deliberately betweennoevictionand an eviction policy, or split them into two instances. - Run
EXPLAIN ANALYZEon your five slowest routes. Look forTable scan, aSortover thousands of rows, androws=far larger than theLIMIT. Add composite indexes with equality columns first, sort column last. - Only then add caching, and only for what's still slow. Use
Cache::tags()on Redis (notdatabaseorfile), and callCache::flush()nowhere in production code. - For each cached expensive value, pick a stampede rule:
Cache::flexibleplus a warm-up on deploy, orCache::lock()->block()with a re-check inside and a build time well under the block timeout. - Move uploads to an S3-compatible disk and generate
temporaryUrl()links; then confirm no pod writes understorage/app. - Put an HPA on CPU with real
requests, removereplicas:from the Deployment, and load test with-disable-keepaliveor clients that reconnect. Keep the 300 s scale-down default until you've watched it in production. - Continue with Part 7 on testing and security and Part 8 on production deployment and monitoring.
Keep reading
Building a Modern SaaS Application with Laravel - Part 1: Multi-Tenancy Architecture & Database Foundations
58 min · 1,087 views
Mobile DevelopmentComplete Solution: Scaling a Node.js Application with Kubernetes and Docker
29 min · 1,025 views
Operating SystemsAdvanced Laravel Tutorial: Queues, Events and Pest Tests
22 min · 276 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 InRelated Articles
Building a REST API with Laravel - Part 1: API Design, Versioning & Sanctum Authentication
May 10, 2026
ChatGPT vs Claude: Real Performance Benchmarks From Production Testing
Apr 22, 2026
Building a Production-Grade E-Commerce Platform with Laravel 12 - Part 2: Product Catalog & Cart with Database Design
May 12, 2026