DEV Community

Elvin Aghalarov
Elvin Aghalarov

Posted on Originally published at hoofbeat.dev

Load testing a Laravel app without fooling yourself

Laravel makes it easy to ship features fast, and just as easy to ship an N+1 query that only hurts at 200 concurrent users. Here's how to load test a Laravel app so the result actually means something.

1. Test production config, not your dev setup

The most common way to waste a load test is to measure the wrong thing.

  • ❌ php artisan serve: single-threaded, one request at a time
  • ❌ APP_DEBUG=true: collects debug data on every request
  • ❌ Telescope or Debugbar recording
  • ✅ PHP-FPM + Nginx, FrankenPHP, or Octane, same as production
  • ✅ APP_ENV=production, APP_DEBUG=false
  • ✅ caches built and OPcache on:
php artisan optimize   # config, routes, events, views
Enter fullscreen mode Exit fullscreen mode
; php.ini (production)
opcache.enable=1
opcache.validate_timestamps=0
opcache.memory_consumption=256
Enter fullscreen mode Exit fullscreen mode

And seed realistic data volumes: an index you don't need with 100 rows is essential with 1,000,000.

2. Handle CSRF like a browser does

Web routes need a CSRF token. A realistic test does what the browser does: load the form, read the token, post it back, keep the cookies. In k6:

import http from 'k6/http';
import { check } from 'k6';
import { SharedArray } from 'k6/data';

const users = new SharedArray('users', () => JSON.parse(open('./users.json')));
const BASE = __ENV.BASE_URL;

export default function () {
  const user = users[(__VU - 1) % users.length]; // a different account per virtual user

  const page = http.get(`${BASE}/login`);
  const token = page.html().find('input[name=_token]').attr('value');

  const res = http.post(`${BASE}/login`, {
    _token: token,
    email: user.email,
    password: user.password,
  });
  check(res, { 'logged in': (r) => r.status === 200 });

  // k6 keeps a cookie jar per virtual user, so the session continues:
  http.get(`${BASE}/orders?status=open`);
}
Enter fullscreen mode Exit fullscreen mode

For API routes with Sanctum, log in once per virtual user, capture the token from the JSON response, and send Authorization: Bearer <token> on the following requests.

3. The usual Laravel bottlenecks

N+1 queries

// 1 query for orders + 50 queries for customers
$orders = Order::latest()->take(50)->get();

// 2 queries total
$orders = Order::with('customer')->latest()->take(50)->get();
Enter fullscreen mode Exit fullscreen mode

Catch them before load testing does:

// AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());
Enter fullscreen mode Exit fullscreen mode

Too few PHP-FPM workers

pm.max_children caps concurrent requests. With 10 workers and 200 ms per request, you top out around 50 requests/second, with CPUs sitting idle.

max_children ≈ memory available for PHP ÷ memory per worker
e.g. 3 GB ÷ 60 MB ≈ 50
Enter fullscreen mode Exit fullscreen mode

Idle CPU + rising latency = a pool is full.

Database connections

Every PHP worker can hold a connection: 4 servers × 50 workers = 200 connections, more than Postgres's default max_connections = 100. Use PgBouncer, or size both sides deliberately.

File-based sessions and cache

File locks serialize requests from the same user. Use Redis (or Valkey) for sessions, cache and queues.

Slow work inside the request

Emails, PDFs, webhooks, slow third-party APIs: queue them. And put a timeout on every outgoing HTTP call:

Http::timeout(5)->retry(2, 200)->post($url, $payload);
Enter fullscreen mode Exit fullscreen mode

Password hashing at login

bcrypt and argon2 are designed to be slow. A login storm after a marketing email can saturate CPUs on its own. Test the login journey separately.

4. Read results per step

If only /orders slows down while login stays flat, it's that query, not "the server". Compare the p95 per step against a baseline run, fix one thing, run the identical test again.

TL;DR

  • Test production config: FPM/Octane, no debug, caches and OPcache on.
  • Script CSRF and tokens the way a browser does, with one account per virtual user.
  • Usual suspects: N+1, FPM workers, DB connections, file sessions, sync work, bcrypt.
  • Idle CPU + rising latency = a pool is exhausted.

Hoofbeat, the load-testing tool I build, is itself a Laravel app. Its flows handle the CSRF/Sanctum capture and per-user datasets for you, and can chart your Prometheus metrics next to latency. Original post on the Hoofbeat blog.

Top comments (1)

Collapse
 
kamrankhandev profile image
Kamran Khan •

Solid checklist. The one I would add is a warmup pass before any measured run: the first run after a deploy is always the worst one, with cold OPcache, a cold buffer pool and uncompiled views. Comparing a fix against a cold baseline makes everything look like an improvement, so run the script once unmeasured, then reset and measure. The other habit that saved me is logging queries per endpoint during the run with DB::listen. p95 tells you something got slow, but it never tells you which query changed.