Framework guide

How to Load Test a Laravel App

Updated October 27, 2026•11 min read•Laravel•PHP

Written by

Reviewed by the LoadTester editorial team. Review process: see the editorial policy.

Published
2026-10-27
Last reviewed
2026-10-27
Author
Kristian Razum
Diagram of nginx feeding a PHP-FPM pool of Laravel workers that share a MySQL connection limit and a Redis queue

To load test a Laravel app, first make the server match production (php artisan optimize, OPcache with timestamp checks off, Redis for sessions and cache), then size PHP-FPM pm.max_children or Octane --workers from memory and Little's Law. Script the test like a real browser: get the session cookie and CSRF token before every POST, or you will measure a wall of 419 responses. Watch the FPM listen queue, database connections and queue wait times. Those three usually fail before PHP CPU runs out.

How Laravel handles concurrent requests

Laravel itself has no concurrency model. The server that runs it does, and you will deploy one of two kinds:

  • PHP-FPM (the classic setup). nginx passes each request to a pool of PHP processes. One process handles one request at a time. Each request boots the framework from scratch: autoloader, service providers, config, routes. OPcache keeps the compiled bytecode in shared memory, so files are not parsed again, but the boot work still runs on every request.
  • Laravel Octane. The app boots once per worker and stays in memory, served by FrankenPHP, Swoole, Open Swoole or RoadRunner. Per the Octane docs, it starts one request worker per CPU core by default, restarts each worker after 500 requests (--max-requests), and has a 30-second max_execution_time.

In both cases a worker is busy for the whole request, including the time it waits on MySQL, Redis or an external API. So the number of requests in flight can never exceed the number of workers. Extra requests wait in a queue: the FPM listen backlog, or the Octane server's socket. This is the main thing to keep in mind when you read the results.

Request path through nginx, the PHP-FPM listen queue, a fixed pool of workers, and shared MySQL and Redis nginx listen queue waits here when full PHP-FPM pool worker 1 (busy) worker 2 (busy) worker ... (busy) max_children 1 request per process MySQL max_connections 151 Redis sessions, cache, queue
Each PHP-FPM worker holds one request and one database connection for the full request. When all workers are busy, new requests wait in the listen queue and latency rises before any error appears.

What usually breaks first in a Laravel app

The FPM pool fills up

The www.conf that ships with PHP sets pm = dynamic and pm.max_children = 5. Distro packages and container images often keep that value. At 5 workers and 150 ms per request, the pool tops out near 33 RPS, which is easy to hit in the first minute of a test. The FPM log tells you directly:

WARNING: [pool www] server reached pm.max_children setting (5), consider raising it

nginx then reports connect() to unix:/run/php/php8.4-fpm.sock failed (11: Resource temporarily unavailable) or upstream timed out. Clients see a 502 or 504.

Database connections and slow queries

Every FPM worker that touches the database opens its own connection. With Octane, each worker keeps one open. Ten servers with 60 workers each means 600 possible connections against MySQL's default max_connections of 151, and you get SQLSTATE[HY000] [1040] Too many connections. Before that point, an Eloquent N+1 on a listing page usually makes every request hold its connection longer. The symptoms match those in database connection pool exhausted.

The database session, cache and queue drivers

New Laravel apps default to SESSION_DRIVER=database, and the skeleton also uses the database for cache and queue. That works fine in development. Under load, it means every request with a session reads and writes the sessions table, and every dispatched job inserts into jobs, so your own app adds write load to the busiest component. Switch these to Redis before you test, or test them on purpose and expect them to show up.

Queues falling behind

A checkout request that dispatches three jobs (email, invoice PDF, webhook) returns quickly, but it adds 3 jobs per request to the queue. At 50 checkouts per second, the workers must finish 150 jobs per second, or the backlog grows for the whole test. The HTTP numbers look fine while customers wait 20 minutes for their receipt.

Server and runtime settings to check before testing

PHP-FPM pool

; /etc/php/8.4/fpm/pool.d/www.conf
pm = static                 ; predictable for a dedicated app server
pm.max_children = 48        ; from the memory math below
pm.max_requests = 1000      ; recycle workers to cap slow leaks
listen.backlog = 511
pm.status_path = /fpm-status
request_slowlog_timeout = 2s
slowlog = /var/log/php-fpm/slow.log

The memory math: measure the real worker size during a warm-up run (ps -o rss= -C php-fpm8.4) instead of guessing. If the server has 4 GB left for PHP after the OS, nginx and any local Redis, and workers average 70 MB, then 4,096 / 70 ≈ 58. Leave headroom for spikes and use about 48.

The concurrency math: by Little's Law, busy workers = RPS × response time. A target of 300 RPS at 120 ms average needs 300 × 0.12 = 36 busy workers. 48 workers cover that with some room. If the math says you need 150 and memory allows 48, you need more servers or faster requests. A higher max_children will only make the box swap.

OPcache

PHP's built-in defaults are opcache.memory_consumption=128 (MB), opcache.max_accelerated_files=10000, opcache.validate_timestamps=1 and opcache.revalidate_freq=2. A Laravel app with its vendor directory can go over 10,000 files, and checking timestamps costs a stat() per file every few seconds:

opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0   ; reload FPM on each deploy instead

Check opcache_get_status() after warm-up. If oom_restarts or hash_restarts are above zero, the cache is too small.

Framework caches

php artisan optimize        # config, events, routes and views cached
APP_DEBUG=false
LOG_LEVEL=warning           # debug logging on every request is real I/O

Also turn off anything that records every request (Telescope, Debugbar) on the target, or you are load testing your debugging tools.

Octane

php artisan octane:start --server=frankenphp --workers=8 --max-requests=1000

Octane changes the failure modes. Requests get cheaper, so the database becomes the limit sooner. Memory leaks from static arrays or singletons that hold the request now build up across requests. Run a soak test long enough for each worker to handle thousands of requests, and watch per-worker RSS.

Laravel tuning table

SettingDefaultWhat to changeWhy
pm.max_children5 (PHP's www.conf)Free RAM ÷ worker RSS, minus headroomCaps requests in flight; 5 caps a typical app at a few dozen RPS
pmdynamicstatic on dedicated app serversNo fork cost during a spike; memory use stays predictable
pm.max_requests0 (never recycle)500 to 1,000Limits slow memory growth from extensions or app code
opcache.max_accelerated_files1000020000Laravel plus vendor can exceed 10k files
opcache.validate_timestamps10 in productionRemoves file stat checks; reload FPM on deploy
SESSION_DRIVER / CACHE_STOREdatabaseredisKeeps session and cache writes off the primary DB
QUEUE_CONNECTIONdatabaseredis (with Horizon)Job inserts and polling stop competing with app queries
Octane --workers1 per CPU coreRaise if requests wait on I/O; measureWorkers block on DB and HTTP calls just like FPM
Octane --max-requests500Keep, or lower if RSS climbsGuards against state leaks in long-lived workers
MySQL max_connections151≥ total workers across all servers + queue workers + marginPrevents error 1040 when the web tier scales out

What to put in the test scenario

A Laravel load test that only requests / measures your page cache or a cheap Blade view. Build the mix from your access logs instead:

  • Read endpoints with real parameters: listing pages with pagination, search, product or record detail pages with IDs spread across the dataset (not the same ID every time, or the query cache does all the work).
  • Authenticated flows: log in once per virtual user, then reuse the session. Logging in on every iteration mostly measures bcrypt; see authentication load testing for when you actually want that.
  • Writes: form submissions, cart updates, API POSTs. These are the requests that take locks and dispatch jobs.
  • The API routes your SPA or mobile app calls, with the same Bearer or Sanctum auth they use.

Sessions and CSRF tokens

Every web route that accepts POST, PUT, PATCH or DELETE goes through CSRF verification. Without a valid token, Laravel returns 419 Page Expired (in logs: CSRF token mismatch). The token is bound to the session, so each virtual user needs to:

  1. GET the page with the form (for example /login) and keep the laravel_session cookie.
  2. Extract the token from <input type="hidden" name="_token" value="..."> or from <meta name="csrf-token" content="...">.
  3. Send it back as the _token form field or an X-CSRF-TOKEN header. SPAs can instead read the XSRF-TOKEN cookie and send its value as X-XSRF-TOKEN.

Do not turn off CSRF on the target "to make testing easier". It changes the code path and teaches you nothing about production. A run with a high rate of 419s is a broken test, not a capacity result.

Rate limits

If routes use the throttle middleware, all your virtual users may share a few source IPs and hit 429 Too Many Attempts within seconds. Either raise the limiter for the test window or key it so the test users do not collide. Then run a separate check that the limiter actually works (rate limit testing).

How to read Laravel-specific signals during the run

SignalWhere to read itWhat it means
listen queue above 0, max children reached climbing/fpm-statusPool saturated; latency now includes queue wait
active processes = total processes while CPU is low/fpm-status, topWorkers are waiting on I/O (DB, Redis, HTTP), not computing
Entries in the FPM slow logslowlog fileStack traces of requests over 2 s; usually a query or HTTP call
Threads_connected near max_connectionsSHOW GLOBAL STATUSConnection limit is next; 1040 errors follow
Horizon wait time rising for a queueHorizon dashboardWorkers cannot keep up with jobs per second
Rising p95 with flat RPSLoad test chartsYou are past the knee; see latency increases under load

The classic Laravel pattern looks like this. p95 is flat until the FPM pool fills, then climbs steeply while RPS stays flat, and CPU sits at 40% because every worker is waiting on a slow query. Adding workers makes it worse, since more concurrent queries slow the database further. Fix the query first. In LoadTester, an exact-RPS test with a p95 threshold and auto-stop finds that knee without pushing far past it.

Testing Horizon and queue workers

Treat queue throughput as its own result. During the HTTP test, record jobs dispatched per second and jobs processed per second per queue. If processing is lower, the backlog grows linearly. Horizon's maxProcesses and balance settings control how many workers each queue gets. Queue workers also use database connections, so add them to the max_connections budget. Let the test run until the queue is drained, and record how long that took. That drain time is what users experience after a traffic spike.

Pre-test checklist

  1. php artisan optimize run, APP_DEBUG=false, Telescope and Debugbar off on the target.
  2. OPcache sized for your file count, timestamps off, status checked after warm-up.
  3. pm.max_children (or Octane workers) set from measured worker RSS and the Little's Law estimate.
  4. pm.status_path and the slow log enabled so you can see saturation, not guess at it.
  5. Sessions, cache and queue on Redis, or a written decision to test the database drivers on purpose.
  6. MySQL max_connections covers all web workers, queue workers and migrations or cron jobs.
  7. Script fetches the session cookie and CSRF token; 419 rate checked in a short smoke run.
  8. Throttle limits adjusted or keyed for the test; load generator IPs allowlisted in any WAF.
  9. Test data spread across many IDs; ramp-up long enough for OPcache and Octane workers to warm.
  10. Horizon dashboard open; queue drain time recorded after the run.

For a first run, LoadTester's Starter plan (250 VUs, 500 RPS, 5 minutes) is enough for most single-server Laravel apps. Find the knee there, fix what breaks, then run longer and larger tests before a launch.

FAQ

Why do my Laravel POST requests return 419 during a load test?

HTTP 419 is Laravel's CSRF token mismatch, shown as 'Page Expired'. Your test is posting without a valid token tied to the session cookie. Fetch the form page first, extract the _token value and keep the session cookie, or send the XSRF-TOKEN cookie value back in an X-XSRF-TOKEN header.

Should I switch to Octane before load testing?

Test what you will run in production. If you are evaluating Octane, run the same scenario against PHP-FPM and Octane and compare p95 and throughput at the same RPS. Octane removes the per-request framework boot, but it will not fix slow queries or a small database connection limit.

How many PHP-FPM workers should a Laravel server have?

Start from memory: available RAM divided by the average worker size, which you can read from ps or the FPM status page. Then check it against Little's Law: busy workers equals RPS times average response time in seconds. If the second number is higher than the first, you need more servers or faster requests, not a higher pm.max_children.

Do I need to run php artisan optimize before testing?

Yes, if you run it in production. It caches config, routes, events and views, which removes file parsing from every request. Testing without these caches measures a slower app than the one you will deploy and points you at the wrong bottleneck.

Why does Laravel slow down when I use the database session driver under load?

With SESSION_DRIVER=database, every request with a session reads and usually writes a row in the sessions table. At high RPS that is extra write load and can cause lock waits on the same table your app queries. Redis is the usual choice for busy apps.

Run this as a real load testFree plan: 10 VUs, 50 RPS, live p95/p99 and error charts, no credit card. Starter $19/mo (250 VUs) or Pro $49/mo (2,000 VUs) with a 7-day free trial when you need more.
Start freeCompare plans