Bekzod Erkinov
Listen to Article
Loading...Table of contents · 9 sections
Building a REST API with Laravel - Part 3: Rate Limiting, Caching & Production Hardening
By the end you will have per-token 429s on a Sanctum API, a Redis cache you can invalidate by tag (including the bulk-update case that silently serves stale data), a queued side effect, security headers, and a deploy-time smoke test for config:cache.
The measured payoff, up front: on my machine, caching a GROUP BY aggregate in Redis made it 3.4x to 5.2x faster (requests per second, details below). Caching a 25-row indexed page showed no clear win. The numbers come from the PHP built-in server on a laptop, so read them for direction, not as capacity figures.
This is Part 3 of a series. It builds on Part 1 (API design, versioning and Sanctum authentication) and Part 2 (resource controllers, validation and API transformers); a fresh Laravel 12 app is enough to follow along.
Setup
You need PHP 8.4 with pdo_sqlite and the phpredis extension, Composer, and a Redis container (I used the redis:8 image). My database was SQLite. The framework versions are from composer show:
$ composer show | grep -E "^laravel/(framework|sanctum) "
laravel/framework 12.69.3
laravel/sanctum 4.3.3
What I did not test, stated once so I don't repeat it below: the limiter on a non-Redis store, isSecure() behind a load balancer, the queue:restart and reload commands, and the concurrent-reader race that motivates flushing after commit. Where those come up, I link the docs instead of showing output.
docker network create p3net
docker run -d --name p3redis --network p3net redis:8
composer create-project laravel/laravel:^12.0 api && cd api
php artisan install:api # Sanctum + routes/api.php
php artisan make:model Product -m
php artisan make:controller ProductController
php artisan make:resource ProductResource
php artisan make:job NotifyPriceChange
php artisan make:middleware SecurityHeaders
php artisan make:test BulkFlushTest
Laravel 12's default cache store is database, per the cache docs, so CACHE_STORE=redis matters: cache tags don't work on the file, database or dynamodb drivers. The User model needs the HasApiTokens trait (it was already there in my app). Everything else in one place:
// .env: DB_CONNECTION=sqlite CACHE_STORE=redis QUEUE_CONNECTION=database
// REDIS_HOST=p3redis REDIS_CLIENT=phpredis API_RATE=5
// config/api.php (create by hand; the only place env() is safe, see the config:cache section)
return [
'rate' => (int) env('API_RATE', 60),
'categories' => ['books', 'games', 'garden', 'kitchen', 'music', 'sports', 'tools', 'toys'],
];
// migration, inside up()
Schema::create('products', function (Blueprint $t) {
$t->id(); $t->string('name'); $t->string('category')->index();
$t->unsignedInteger('price_cents'); $t->timestamps();
});
// database/seeders/DatabaseSeeder.php, inside run()
User::create(['name' => 'Demo', 'email' => '[email protected]', 'password' => Hash::make('secret-pass')]);
$cats = config('api.categories');
for ($c = 0; $c < 50; $c++) {
$rows = [];
for ($i = 0; $i < 1000; $i++) {
$n = $c * 1000 + $i;
$rows[] = ['name' => "Product $n", 'category' => $cats[$n % 8], 'price_cents' => 100 + ($n * 37) % 9900,
'created_at' => now(), 'updated_at' => now()];
}
DB::table('products')->insert($rows);
}
// app/Http/Resources/ProductResource.php, toArray()
return ['id' => $this->id, 'name' => $this->name, 'category' => $this->category, 'price_cents' => $this->price_cents];
php artisan migrate --seed
php artisan tinker --execute='echo App\Models\User::first()->createToken("bench")->plainTextToken;' > /tmp/token
php artisan queue:work # separate terminal; needed for the queue section
The raw/* routes below and the stats endpoint exist only for the benchmark; skip them in a real app.
Laravel API rate limiting per user and per API token
Define named limiters in AppServiceProvider::boot(). This is the pattern from the routing docs. I key authenticated traffic on the token ID, so a script using one token spends that token's budget only:
// AppServiceProvider.php
// use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('api', function (Request $request) {
$token = $request->user()?->currentAccessToken();
return $token
? Limit::perMinute(config('api.rate'))->by('token:'.$token->id)
: Limit::perMinute(20)->by('ip:'.$request->ip());
});
RateLimiter::for('writes', fn (Request $request) => Limit::perMinute(5)
->by('write:'.$request->user()?->currentAccessToken()?->id)
->response(fn ($request, array $headers) => response()->json(
['message' => 'Too many writes, slow down.'], 429, $headers)));
RateLimiter::for('login', fn (Request $request) => [
Limit::perMinute(5)->by('login:'.strtolower((string) $request->input('email')).'|'.$request->ip()),
Limit::perMinute(20)->by('login-ip:'.$request->ip()),
]);
Choosing the numbers. The values here are demo-sized, not recommendations. A rule that works: set the per-token limit just above what your busiest legitimate client sends in a minute, then lower it if abuse shows up. Set the write limit by how often a real user can change data (5 per minute is fine for a price-edit screen, wrong for a bulk importer). Keep the anonymous per-IP limit lower than the token limit, because an IP is shared by many people behind NAT and a token is not. Login gets the tightest limit because a password guesser is the one caller you want slowed. Measure your own traffic first.
Session auth caveat. The token: key assumes a personal access token. Sanctum's docs say SPA authentication uses cookie sessions and "does not use tokens of any kind", so for those users currentAccessToken() won't give you a real token row id. If you serve SPA users, fall back to $request->user()->id in the key, or every such user shares one bucket. I did not run the SPA path.
The login limiter returns an array of limits: one bucket per email+IP and one per IP. Point the throttle middleware at Redis and register the headers middleware in bootstrap/app.php:
// bootstrap/app.php: use App\Http\Middleware\SecurityHeaders; (the Application, Exceptions and Middleware imports are already there)
->withMiddleware(function (Middleware $middleware): void {
$middleware->throttleWithRedis();
$middleware->api(append: [SecurityHeaders::class]);
})
Attach the limiters in routes/api.php:
// routes/api.php
// use App\Http\Controllers\ProductController; use App\Models\User; use Illuminate\Http\Request;
// use Illuminate\Support\Facades\{Hash, Route};
Route::prefix('v1')->group(function () {
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
Route::get('products', [ProductController::class, 'index'])
->middleware('cache.headers:private;max_age=60;etag');
Route::get('products/stats', [ProductController::class, 'stats']);
Route::put('products/{product}', [ProductController::class, 'update'])->middleware('throttle:writes');
// same data, uncached, benchmark only
Route::get('raw/products', [ProductController::class, 'indexUncached']);
Route::get('raw/stats', [ProductController::class, 'statsUncached']);
});
Route::post('login', function (Request $r) {
$u = User::where('email', $r->input('email'))->first();
abort_unless($u && Hash::check($r->input('password'), $u->password), 401);
return ['token' => $u->createToken('api')->plainTextToken];
})->middleware('throttle:login');
});
With API_RATE=5, seven requests with one token (curl -D -, headers trimmed):
HTTP/1.1 200 OK X-RateLimit-Limit: 5 X-RateLimit-Remaining: 4
HTTP/1.1 200 OK X-RateLimit-Limit: 5 X-RateLimit-Remaining: 3
HTTP/1.1 200 OK X-RateLimit-Limit: 5 X-RateLimit-Remaining: 2
HTTP/1.1 200 OK X-RateLimit-Limit: 5 X-RateLimit-Remaining: 1
HTTP/1.1 200 OK X-RateLimit-Limit: 5 X-RateLimit-Remaining: 0
HTTP/1.1 429 Too Many Requests X-RateLimit-Limit: 5 X-RateLimit-Remaining: 0 Retry-After: 60 X-RateLimit-Reset: 1791414446
HTTP/1.1 429 Too Many Requests X-RateLimit-Limit: 5 X-RateLimit-Remaining: 0 Retry-After: 60 X-RateLimit-Reset: 1791414446
The writes limiter has a custom ->response(); the api limiter doesn't, so it returns Laravel's default 429. I started a second server with API_RATE=2 and sent three requests per APP_DEBUG value:
--- APP_DEBUG=true status codes: 200 200 429
{
"message": "Too Many Attempts.",
"exception": "Illuminate\\Http\\Exceptions\\ThrottleRequestsException",
"file": "/root/api/vendor/laravel/framework/src/Illuminate/Routing/Middleware/ThrottleRequests.php",
"line": 254,
"trace": [ ... 10885 bytes in total
--- APP_DEBUG=false status codes: 429 429 429 (the bucket was still spent from the first run)
{
"message": "Too Many Attempts."
} (39 bytes)
With APP_DEBUG=true the 429 leaks file paths and a stack trace; with false it is the clean body above. Check it with curl -i before you ship.
The login test, seven wrong passwords in a loop:
for i in 1 2 3 4 5 6 7; do
curl -s -o /dev/null -w "%{http_code} " -X POST localhost:8000/api/v1/login \
-H "Accept: application/json" -d "[email protected]&password=nope$i"
done
# 401 401 401 401 401 429 429
Keep auth:sanctum before the throttle, as in the routes above, so the limiter sees the user.
Rate limiting runs before the cache
throttle:api sits in front of the controller, so a response served from the Redis cache, or a 304, still spends one request from the token's budget. I checked it with API_RATE=5, a flushed limiter DB, one warm-up request for the ETag, then GET /products twice (cache hits), one with If-None-Match, and two more:
HTTP/1.1 200 OK X-RateLimit-Remaining: 3
HTTP/1.1 200 OK X-RateLimit-Remaining: 2
HTTP/1.1 304 Not Modified X-RateLimit-Remaining: 1
HTTP/1.1 200 OK X-RateLimit-Remaining: 0
HTTP/1.1 429 Too Many Requests X-RateLimit-Remaining: 0
Redis or the default limiter?
I used throttleWithRedis() as above and only tested it that way. Whatever store you pick, every app server has to share it, or each server keeps its own counter.
Laravel response caching with Redis tags and invalidation
The controller, in full (the Uncached and stats methods are benchmark scaffolding; the resource-plus-controller shape follows Part 2):
namespace App\Http\Controllers;
use App\Http\Resources\ProductResource;
use App\Jobs\NotifyPriceChange;
use App\Models\Product;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
class ProductController extends Controller
{
public function index(Request $request): JsonResponse
{
// Validate before keying: an unbounded category or page would let a client mint cache keys.
$v = $request->validate([
'category' => ['nullable', Rule::in(config('api.categories'))],
'page' => 'nullable|integer|min:1|max:2000',
]);
$category = $v['category'] ?? null;
$page = (int) ($v['page'] ?? 1);
$key = 'products:index:'.($category ?? 'all').":$page";
$payload = Cache::tags(['products'])->remember($key, 300, function () use ($category, $page) {
$p = Product::query()
->when($category, fn ($q) => $q->where('category', $category))
->orderBy('id')->paginate(25, page: $page);
return ProductResource::collection($p)->response()->getData(true);
});
return response()->json($payload);
}
public function update(Request $request, Product $product): JsonResponse
{
$data = $request->validate(['price_cents' => 'required|integer|min:0']);
DB::transaction(function () use ($product, $data) {
$product->update($data);
NotifyPriceChange::dispatch($product->id, $data['price_cents'])->afterCommit();
});
return response()->json(new ProductResource($product));
}
public function stats(): JsonResponse
{
return response()->json(Cache::tags(['products'])->remember('products:stats', 300, fn () => $this->statsQuery()));
}
public function indexUncached(Request $request): JsonResponse
{
$p = Product::query()->orderBy('id')->paginate(25, page: max(1, (int) $request->query('page', 1)));
return response()->json(ProductResource::collection($p)->response()->getData(true));
}
public function statsUncached(): JsonResponse
{
return response()->json($this->statsQuery());
}
private function statsQuery(): array
{
return ['by_category' => DB::table('products')
->select('category', DB::raw('count(*) as n'), DB::raw('avg(price_cents) as avg_price'))
->groupBy('category')->orderBy('category')->get()->all()];
}
}
The key is built from the parameters the endpoint honours, not $request->getQueryString(), so ?page=1&x=1 and ?page=1&x=2 share an entry. Every parameter that goes into the key is validated: ?category=a1, ?category=a2, ... would each mint a key otherwise. Rule::in against a whitelist and a page cap (50,000 rows at 25 per page is 2,000 pages) bound the key space. Results from this app, starting from an empty cache:
category=books -> 200
category=zzz -> 422
category=a1,a2 -> 422
page=0 -> 422
page=99999 -> 422
x=1&page=2 -> 200
x=2&page=2 -> 200
Invalidate with a model event so you can't forget. I flush after the transaction commits, not inside it, so a reader can't re-cache the old row between the flush and the commit (that race is my reasoning, not something I reproduced). What I did show is the listener order, from one transaction with both kinds of listener:
flush (in saved, old)
end of closure
flush (afterCommit, new)
COMMIT
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class Product extends Model
{
protected $fillable = ['name', 'category', 'price_cents'];
protected static function booted(): void
{
$flush = fn () => DB::afterCommit(fn () => Cache::tags(['products'])->flush());
static::saved($flush);
static::deleted($flush);
}
}
A tag flush is coarse: any product write drops every cached products page. Don't use Cache::flush() instead: the cache docs say it ignores your prefix and removes everything in the store.
The stale-data trap
With the afterCommit version above, I read product 1 (price 100), updated it through the API, then did a bulk update and read again:
price of product 1 (cached read): 100
PUT price_cents=777 (model event flushes tag after commit)
price now: 777
bulk updated # Product::where('category','books')->update(['price_cents' => 1])
price after bulk update (stale?): 777
flushed # Cache::tags(['products'])->flush()
price after flush: 1
Mass update() goes straight to the query builder, so no model events fire and the cache kept serving 777 when the database said 1. Any bulk update, import, upsert or raw SQL needs an explicit Cache::tags([...])->flush() next to it. Put it in the one class that owns bulk writes and pin it with a test:
// app/Services/ProductBulkWrites.php
namespace App\Services;
use App\Models\Product;
use Illuminate\Support\Facades\Cache;
class ProductBulkWrites
{
public function setCategoryPrice(string $category, int $cents): int
{
$n = Product::where('category', $category)->update(['price_cents' => $cents]);
Cache::tags(['products'])->flush();
return $n;
}
}
// tests/Feature/BulkFlushTest.php
namespace Tests\Feature;
use App\Models\Product;
use App\Services\ProductBulkWrites;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
class BulkFlushTest extends TestCase
{
use RefreshDatabase;
public function test_bulk_update_flushes_products_tag(): void
{
Product::create(['name' => 'p', 'category' => 'books', 'price_cents' => 5]);
Cache::tags(['products'])->put('products:index:all:1', 'stale', 300);
app(ProductBulkWrites::class)->setCategoryPrice('books', 1);
$this->assertNull(Cache::tags(['products'])->get('products:index:all:1'));
}
}
$ php artisan test --filter=BulkFlushTest
PASS Tests\Feature\BulkFlushTest
Tests: 1 passed (1 assertions)
# same test with the Cache::tags(...)->flush() line deleted:
FAILED Tests\Feature\BulkFlushTest > bulk update flushes products tag
Failed asserting that 'stale' is null.
Tests: 1 failed (1 assertions)
ETags with the built-in cache.headers middleware
Laravel's cache.headers middleware (already on the products route above) adds Cache-Control and an ETag unless the response already has one. In my 12.69.3 vendor/ copy:
$ grep -n "xxh128" Http/Middleware/SetCacheHeaders.php
66: $options['etag'] = $response->getEtag() ?? ($response->getContent() ? hash('xxh128', $response->getContent()) : null);
Output from curl -D -, then a repeat with the ETag in If-None-Match:
HTTP/1.1 200 OK
Cache-Control: max-age=60, private
ETag: "9b89c2cd19565d48e4d76baecd2d8e26"
etag="9b89c2cd19565d48e4d76baecd2d8e26" body bytes=3416
--- revalidate with If-None-Match
HTTP/1.1 304 Not Modified
ETag: "9b89c2cd19565d48e4d76baecd2d8e26"
body bytes: 0
That's the standard revalidation flow: a matching If-None-Match returns 304 with no body. I used private because the response is per-user. The 304 saves bandwidth but not server work: the hash is of the controller's response, so the controller still runs.
Push slow side effects onto a queue
Say changing a price must call a webhook that takes about two seconds. I simulated that with sleep(2) in the job (a stand-in, not a real webhook):
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class NotifyPriceChange implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public function __construct(public int $productId, public int $newPrice) {}
public function handle(): void
{
sleep(2);
Log::info("price webhook sent for product {$this->productId} => {$this->newPrice}");
}
}
The controller above dispatches inside the transaction with afterCommit(), so a rollback never sends a notification for a price that didn't change. Request time for the PUT on this run:
QUEUE_CONNECTION |
Time |
|---|---|
sync |
3.05 s |
database |
1.07 s (first request, right after my .env edit restarted artisan serve), then 0.10 s |
With the database connection a worker must be running; php artisan queue:work --stop-when-empty logged NotifyPriceChange ... 2s DONE for each queued job.
On deploys, the 12.x queues docs say: "Since queue workers are long-lived processes, they will not notice changes to your code without being restarted," and document php artisan queue:restart for that. The deployment docs document a single php artisan reload command for long-running services. Graceful Shutdown Patterns for Laravel Queue Workers in Production covers the worker side.
Security headers in one middleware
Fill in the SecurityHeaders class generated earlier:
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SecurityHeaders
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Referrer-Policy', 'no-referrer');
$response->headers->set('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'");
$response->headers->set('Cross-Origin-Resource-Policy', 'same-origin');
if ($request->isSecure()) {
$response->headers->set('Strict-Transport-Security', 'max-age=31536000');
}
return $response;
}
}
curl -i against the running app (plain HTTP) showed these on responses:
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
Cross-Origin-Resource-Policy: same-origin
HSTS is absent from that output because the test was over HTTP. To see it, I built one https:// and one http:// request in a PHP script and passed each through the HTTP kernel:
https://localhost -> 200 HSTS: max-age=31536000
http://localhost -> 200 HSTS: (absent)
Behind a load balancer, check this. The requests docs say that behind a load balancer that terminates TLS, the app may not know it should generate secure links, and that you configure trusted proxies with trustProxies in bootstrap/app.php. My HSTS line depends on isSecure(), so if the app sees plain HTTP from the balancer the header won't be sent. curl -i https://your-host against production is the check.
The header values above (including the one-year HSTS max-age and same-origin CORP) are my choices for a JSON-only API. MDN says browsers ignore the HSTS header over HTTP. Also set APP_DEBUG=false: the deployment docs say debug mode in production can expose sensitive configuration. For the rest of the list, see Security Hardening a Laravel App: OWASP Top 10 in Practice.
Laravel config:cache and route:cache in production
php artisan optimize caches config, events, routes and views (per the deployment docs); my output:
config ........ DONE
events ........ DONE
routes ........ DONE
views ......... DONE
My first version of the limiter read env('API_RATE', 60) directly inside AppServiceProvider. After optimize, the limit fell back to 60 even though .env said otherwise. The deployment docs say that once config is cached, .env isn't loaded and env() outside config files returns null. The fix is config('api.rate') with env() only in config/api.php.
The CI smoke test I used is a 6-line shell script that runs after optimize against a server on port 8002 (PORT defaults to 8000), with API_RATE=7 in .env:
LIMIT=$(curl -s -D - -o /dev/null localhost:${PORT:-8000}/api/v1/products \
-H "Authorization: Bearer $(cat /tmp/token)" -H "Accept: application/json" \
| tr -d "\r" | awk -F": " 'tolower($1)=="x-ratelimit-limit"{print $2}')
[ "$LIMIT" = "7" ] && echo "ok: limit=$LIMIT" || { echo "FAIL: limit=$LIMIT"; exit 1; }
--- env() in provider, after optimize, plain `php -S 0.0.0.0:8002 -t public public/index.php`:
FAIL: limit=60
--- config() after optimize:
ok: limit=7
Run the smoke test against php -S or PHP-FPM, not php artisan serve.
optimize:clear also clears your response cache
The deployment docs say optimize:clear removes the generated cache files "as well as all keys in the default cache driver". On my setup, with the default config/database.php (limiter on Redis DB 0 via throttleWithRedis(), cache on REDIS_CACHE_DB=1), keys before and after:
db0: ["laravel-database-666611363ba75146010fbd05d5e1ae13"] <- limiter key
db1: [".../products:index:all:2", ".../products:index:books:1", ".../tag:products:entries"]
$ php artisan optimize:clear # config, cache, compiled, events, routes, views: DONE
db0: ["laravel-database-666611363ba75146010fbd05d5e1ae13"]
db1: []
The cached product pages were gone and the limiter key survived. So on a deploy, optimize:clear gives you a cold response cache. If you want to keep it, run config:clear and route:clear instead, or accept the cold start.
What the load test showed
Setup: server PHP_CLI_SERVER_WORKERS=4 php artisan serve --host=0.0.0.0 --port=8000 in a php:8.4-cli container on the p3net network (SQLite, Redis cache, 50,000 rows, API_RATE=100000 so the limiter never fires). Hardware: Intel Core i7-8550U (4 cores / 8 threads), 31.8 GB RAM, Windows 11, Docker Desktop on its WSL 2 backend, with Docker reporting 8 CPUs and about 7.8 GiB of memory available (from docker info). Each cell is one run of
docker run --rm --network p3net httpd:2.4-alpine ab -n 1000 -c 8 \
-H "Authorization: Bearer $TOKEN" -H "Accept: application/json" http://p3php:8000/api/v1/raw/stats
(the ApacheBench in that image; the 304 row adds -H "If-None-Match: <etag>"). Runs alternate: A after php artisan optimize:clear, B after php artisan optimize, in the order A1 B1 A2 B2 A3 B3.
Headline: cache the GROUP BY aggregate, skip the 25-row indexed page. The aggregate was 3.4x to 5.2x faster cached; for the page there was no clear win. A decision rule from these numbers: cache an endpoint when its uncached cost is well above the cost of booting the framework, and measure your own endpoint first.
Caveats: uncached rows hit the raw/* routes, so they differ from cached rows by controller path as well as the cache; it ran on PHP's built-in server, not PHP-FPM; and twelve runs on a noisy machine is a small sample (set 2 ran while other containers were busy). The 304 row's range overlaps the 200 rows, so it shows no server-side saving, which matches the ETag section.
Requests per second, median of the six runs in each set (median over all 12 runs and range in the last two columns):
| Endpoint | Set 1 median | Set 2 median | Median (12) | Range |
|---|---|---|---|---|
| Aggregate, no cache | 15.35 | 7.62 | 10.41 | 4.66-16.51 |
| Aggregate, Redis cache | 63.30 | 26.07 | 54.30 | 22.81-80.51 |
| List page (25 rows), no cache | 51.18 | 32.44 | 42.21 | 20.11-60.98 |
| List page, Redis cache | 61.02 (5 runs) | 25.85 | 38.68 (11 runs) | 19.23-79.34 |
List page + If-None-Match (304) |
58.56 | 26.07 | 48.64 | 18.96-72.38 |
- Aggregate: 54.30 vs 10.41 requests/s pooled (5.2x); 4.1x within set 1 and 3.4x within set 2. The ranges (22.81-80.51 vs 4.66-16.51) don't overlap, and the cached run was faster in all 12 run pairs.
- List page: the ranges overlap heavily (19.23-79.34 vs 20.11-60.98), the cached median (38.68) is below the uncached one (42.21), and the direction flips between sets (cached higher in set 1, lower in set 2).
- I did not isolate the effect of
optimize; I run it because the deployment docs recommend it.
All raw runs and the excluded run (requests/s, order A1 B1 A2 B2 A3 B3)
The struck-through cell (set 1, B2, cached list) made ab report 553 "Failed requests", so its 69.06 req/s is excluded from that row. I reran the same request three times and got 34.41, 40.24 and 42.36 req/s with Failed requests: 0; I could not reproduce the failures or find a cause, and I did not rerun that cell under the original conditions. The 304 row shows every response as "Non-2xx" in ab.
| Endpoint | Set 1 | Set 2 |
|---|---|---|
| Aggregate, no cache | 11.86 10.79 16.51 15.06 15.63 15.97 | 7.70 9.05 10.02 7.53 4.66 6.24 |
| Aggregate, Redis cache | 42.05 60.21 63.26 64.22 63.34 80.51 | 23.91 60.64 48.39 22.81 24.36 27.77 |
| List page (25 rows), no cache | 45.88 50.81 37.86 51.54 55.22 60.98 | 23.76 56.57 38.54 36.25 20.11 28.62 |
| List page, Redis cache | 37.46 71.53 40.88 |
26.55 63.16 38.68 25.15 19.23 23.74 |
List page + If-None-Match (304) |
46.32 58.82 53.47 72.38 58.81 58.31 | 33.54 50.95 29.16 22.72 18.96 22.97 |
Next steps
- Paste the
optimizesmoke test above into your CI, run it against a server that isn'tartisan serve, and let it fail the deploy whenX-RateLimit-Limitisn't what.envsays. - Wire the
BulkFlushTestpattern into CI for every bulk-write path (imports,upsert, raw SQL): one test per path that seeds a cache entry, runs the write, and asserts the entry is gone. - Work through the rest of the production checklist in Security Hardening a Laravel App: OWASP Top 10 in Practice.
Keep reading
Building a Modern SaaS Application with Laravel - Part 1: Multi-Tenancy Architecture & Database Foundations
58 min · 1,076 views
DevOpsBuild a Next.js and MongoDB Blog: Schema, Indexes, Caching
8 min · 253 views
Operating SystemsAdvanced Laravel Tutorial: Queues, Events and Pest Tests
22 min · 248 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