Skip to content

·20 min read

Islam A-ElwafaUpdated

Disclosure: VoltTest is our own product. Where these guides compare it with other tools, we aim to be accurate about the cases where an alternative is the better choice — but weigh the comparison accordingly.

How to Stress Test PHP and Laravel Applications

Choosing a PHP stress test tool usually means a painful trade-off: the popular load testing tools all live outside PHP, so you end up writing scenarios in JavaScript, Java, or XML instead of the language your app is built in. This guide takes a different approach. We'll cover what stress testing actually means for a PHP application, the bottlenecks that PHP apps specifically hit under load, what to look for in a tool, how the main options compare, and how to run your first stress test on plain PHP or Laravel in just a few minutes — without leaving your Composer workflow.

PHP Stress Testing Tool

What Is Stress Testing in PHP?

Stress testing pushes your application beyond its normal operating limits to find the point where it breaks — and to see how it breaks. It answers questions a single request never can: How many concurrent users can your API handle before response times spike? Does the database connection pool exhaust under load? Does the app fail gracefully or fall over?

It's easy to confuse the different flavors of performance testing. The difference isn't the tool — it's the question you're asking:

Load testingStress testingSpike testing
QuestionDoes it hold up at expected traffic?Where does it break, and how?Does it survive a sudden surge?
Load profileRamp to target, then holdKeep climbing past targetJump straight to a multiple of target
Pass conditionP95 under budget, errors near zeroYou found the limit and it degraded gracefullyIt recovers once the surge passes
Typical triggerPre-release regression checkCapacity planning before a campaignBlack Friday, a launch, a viral post

They overlap heavily and most tools (including VoltTest) handle all three. For a full breakdown of each type, see Performance Testing Types. For the rest of this article we'll use "stress testing" loosely to mean putting your PHP app under realistic concurrent load.

The Knee Matters More Than the Breaking Point

Most teams stress test looking for the moment the application starts returning errors. That number is the least useful thing the test produces, because you will never run production anywhere near it.

What you actually want is the knee — the virtual-user count where P95 latency stops rising gently and starts bending upward while throughput flattens. Below the knee, adding users adds throughput. Above it, adding users only adds queueing: the same requests per second, served more slowly to more people. The breaking point — 5xx responses, timeouts, connection resets — usually arrives well after the knee, and by then your users have been having a bad time for a while.

So a useful PHP stress test produces three numbers, not one:

  • The knee — where latency starts bending. This is your real capacity.
  • The breaking point — where errors start. The gap between it and the knee is your headroom.
  • Recovery time — how long the system takes to return to baseline once load stops. An app that needs ten minutes to drain a queue has a very different failure mode from one that recovers in ten seconds.

Common PHP Bottlenecks Under Stress

Under load, a PHP application almost never fails where you expect. The code you spent an afternoon optimizing is rarely the constraint — the constraint is usually a fixed-size resource underneath it: a worker pool, a connection limit, a lock, a queue. Stress testing is how you find out which one you run out of first.

These five account for most PHP failures under load. For each: what actually breaks, the signature it leaves in your metrics, and how to reproduce it deliberately.

1. PHP-FPM Worker Saturation

PHP-FPM serves each request from a pool of worker processes capped by pm.max_children. When every worker is busy, new requests don't fail — they queue in the socket backlog. Then they wait.

The arithmetic is unforgiving. With pm.max_children = 20 and an average request taking 100ms, your ceiling is roughly 200 requests per second no matter how many virtual users you throw at it. This is the first wall most PHP apps hit, and it's routinely misdiagnosed as a slow database.

The signature: throughput plateaus at a hard ceiling while latency climbs in a straight line. Double the virtual users and RPS doesn't move, but P95 roughly doubles. Push further and nginx starts returning 502 Bad Gateway or 504 Gateway Timeout.

Reproduce it by ramping past the ceiling in stages and watching where the RPS line goes flat:

breakpoint-test.php
<?php
require 'vendor/autoload.php';
 
use VoltTest\VoltTest;
 
$voltTest = new VoltTest('Breakpoint Test');
 
// A step ladder: each stage ramps linearly from the previous target.
// Stages replace setVirtualUsers/setDuration — don't use both.
$voltTest->stage('1m', 50)      // warm up
    ->stage('2m', 200)          // climb
    ->stage('2m', 500)          // past the expected ceiling
    ->stage('1m', 0);           // ramp down and watch recovery
 
$scenario = $voltTest->scenario('Product Listing');
$scenario->step('Browse products')
    ->get('https://staging.example.com/products')
    ->validateStatus('Products OK', 200);
 
$voltTest->run(true);

The fix is usually configuration, not code: raise pm.max_children (bounded by available RAM ÷ average process size — over-provisioning here just moves the failure to swap), or shorten the time each worker is held. Shortening it generally means fixing one of the next four.

2. Database Connections and N+1 Amplification

Two distinct database problems show up under stress, and they look different.

The first is connection exhaustion. MySQL's max_connections and your FPM worker count are independent numbers that have to agree. Two hundred FPM workers each opening a connection against a max_connections = 151 server produces SQLSTATE[HY000] [1040] Too many connections — an application-level 500, not a slow response.

The second is N+1 amplification, and it's the more common one. A page that issues 30 queries at one virtual user issues 3,000 at a hundred. The query that took 2ms in isolation now waits behind ninety-nine copies of itself. Nothing in your single-request profiling predicts this, because the cost isn't in the query — it's in the contention.

The signature: latency climbs with a long tail. P99 degrades far faster than P95 (a 5× gap between them is a strong hint), and the database host shows high CPU or lock waits while your PHP servers sit relatively idle.

Reproduce it with realistic data variation, not one URL repeated. Hitting /products/1 ten thousand times measures your query cache; hitting ten thousand different product IDs measures your database. VoltTest reads a CSV data source for this, in sequential, random, or unique mode, and interpolates the column into the request:

use VoltTest\DataSourceConfiguration;
 
$scenario->setDataSourceConfiguration(
    new DataSourceConfiguration(__DIR__ . '/product_ids.csv', 'random', true)
);
 
$scenario->step('Product detail')
    ->get('https://staging.example.com/products/${product_id}')
    ->validateStatus('Product OK', 200);

3. Session Locking

This is the PHP-specific bottleneck that surprises people most, because it doesn't exist in most other runtimes.

PHP's default file-based session handler takes an exclusive lock on the session file for the duration of the request. Two concurrent requests carrying the same session cookie cannot run in parallel — the second one blocks until the first finishes and releases the lock. A modern page firing six AJAX requests at once executes them strictly one after another.

The signature: latency scales with requests per session, not with virtual users. CPU sits idle on every tier while response times climb. The tell-tale comparison: the same steps run dramatically faster when the scenario doesn't carry cookies.

That last point is also the reason most stress tests miss this entirely. If every virtual user is cookie-less and anonymous, there's no shared session and no lock to contend for — the test passes cleanly and production still stalls. Session persistence is opt-in:

$scenario = $voltTest->scenario('Authenticated Browsing')
    ->autoHandleCookies();   // without this, no session, no lock contention

The fix: move sessions to a non-locking handler (Redis with locking disabled), or call session_write_close() as soon as the request is done writing to the session — which releases the lock while the rest of the request runs.

4. Cache Misses and Stampede

Your application is fast because the cache is warm. Stress testing a warm cache measures the cache, not the application.

The dangerous moment is the stampede: a popular key expires, and every in-flight request misses simultaneously. All of them try to regenerate the same expensive value at once, and a query that normally runs once per five minutes runs five hundred times in one second. Under enough concurrency this alone can take down the database.

The signature: steady-state numbers look excellent, but a spike profile — or the first sixty seconds after a deploy or cache:clear — shows a wall of latency followed by recovery. If your P95 is fine and your P99.9 is catastrophic, suspect regeneration.

Reproduce it by flushing the cache immediately before a spike stage, so the surge lands on a cold cache the way a deploy does:

$voltTest->stage('10s', 500)    // near-instant surge onto a cold cache
    ->stage('2m', 500)          // hold, and watch whether it recovers
    ->stage('30s', 0);

The fix: single-flight regeneration (a lock so one request rebuilds while the others wait or serve stale), and staggered TTLs so keys written together don't expire together.

5. Queue Backlogs

Queues are the bottleneck a stress test is most likely to declare healthy while the feature is broken.

An endpoint that dispatches a job returns in 15ms whether the worker pool is idle or forty thousand jobs behind. Your HTTP metrics stay green — 100% success, low P95 — while order confirmations go out three hours late. The load test passed; the system failed.

The signature: there isn't one in the HTTP metrics. That's the point. You have to watch queue depth and job latency alongside the run, and treat "backlog still draining ten minutes after the test ended" as a failure even when every request succeeded.

On Laravel, that means watching Horizon (or queue:monitor) during the run and checking failed_jobs afterwards. Worker throughput is a fixed capacity like any other: if the endpoint can dispatch 500 jobs/second and your workers process 50, you have a 10× deficit that only shows up as a growing number somewhere else.

Telling Them Apart

When a stress test goes bad, the combination of symptoms usually narrows it to one suspect:

SymptomMost likely cause
RPS flat, latency linear, PHP hosts at 100% CPUFPM workers or CPU-bound code
RPS flat, latency linear, everything idleSession locking, or a blocking external call
P99 far worse than P95, DB host busyN+1 amplification or lock contention
Fine in steady state, terrible for the first minuteCold cache / stampede
Every HTTP metric green, work not actually doneQueue backlog
500s with SQLSTATE 1040Connection limit mismatch with FPM workers

What to Look For in a PHP Stress Testing Tool

Not every load testing tool fits a PHP team. Before picking one, check it against these criteria:

  • PHP- and Composer-native — can you write tests in PHP and install via Composer, or do you have to learn a separate language and toolchain?
  • Realistic multi-step scenarios — real users log in, browse, add to cart, and check out. The tool should chain requests and pass data (tokens, IDs, CSRF) between steps.
  • Ramp-up and stages — instantly hitting your app with 1,000 users isn't realistic. You want ramp-up, hold, spike, and ramp-down profiles.
  • Percentile metrics — averages hide problems. You need P95 and P99 latency, requests per second, and success/error rates.
  • Session and cookie handling — without it you only ever test anonymous traffic, and PHP's session lock (see above) never shows up.
  • CI/CD integration — stress tests are most valuable when they run automatically and catch regressions before they ship.
  • Assertions — the ability to fail a build when, say, P95 exceeds 150ms.
  • Scale — can it generate enough concurrent virtual users to actually stress your infrastructure?

The first point is where most tools fall short for PHP developers — and where the comparison below gets interesting.

PHP Stress Testing Tools Compared

Here's an honest look at the most common options for stress testing a PHP application:

ToolWrite tests inPHP-nativeLaravel-nativeMulti-step scenariosBest for
VoltTestPHPYesYesYesPHP/Laravel teams who want native tests on a fast engine
k6JavaScriptNoNoYesJS-heavy teams comfortable scripting in JS
JMeterXML / GUINoNoYesProtocol breadth and complex enterprise plans
LocustPythonNoNoYesPython teams
Apache Bench (ab)CLI flagsNoNoNoQuick one-off single-endpoint checks

There's no single "best" tool — it depends on your stack:

  • k6 is excellent if your team already lives in JavaScript. (VoltTest vs k6 covers where each one fits.)
  • JMeter is the veteran for protocol breadth and large enterprise test plans, at the cost of a heavier, GUI-driven workflow. (VoltTest vs JMeter.)
  • Apache Bench is perfect for a quick ab -n 1000 -c 50 sanity check on a single URL, but can't model real user journeys.
  • VoltTest is built specifically for PHP and Laravel developers: you write tests in PHP, install with Composer, and a Go engine handles the actual load generation — so you get PHP's ergonomics with Go's concurrency. It's the only option here with a first-party Laravel package — and on Laravel it gives you the same one-line speed as ab (php artisan volttest:run <url>) and full multi-step scenarios when you need them. What it gives up for that focus: HTTP only (no JDBC, JMS or gRPC workloads), status-code-only per-step assertions, and the shortest track record of anything in this table. JMeter and k6 are the better tools when you need protocol breadth or richer assertions.

The next sections show exactly how that works — from a stress test in plain PHP, to a full Laravel scenario, to a one-line CLI test you can fire off like ab.

Stress Testing Plain PHP with VoltTest

VoltTest's core SDK is framework-agnostic — it works with any PHP application, whether that's Symfony, WordPress, or raw PHP. Install it with Composer:

composer require volt-test/php-sdk

Then define a scenario and run it. Here's a minimal stress test that hits your homepage:

stress-test.php
<?php
require 'vendor/autoload.php';
 
use VoltTest\VoltTest;
 
// Create a new test
$voltTest = new VoltTest('Homepage Stress Test');
 
// Configure the load: 50 virtual users, ramped up over 10s, held for 1 minute
$voltTest->setVirtualUsers(50)
    ->setRampUp('10s')
    ->setDuration('1m');
 
// Define a scenario with one or more steps
$scenario = $voltTest->scenario('Browse Homepage');
$scenario->step('Load homepage')
    ->get('https://your-app.test/')
    ->validateStatus('Homepage OK', 200);
 
// Run the test — passing true streams the live report to your console
$voltTest->run(true);

Run it like any PHP script:

php stress-test.php

Because the heavy lifting happens in VoltTest's Go engine, a single machine can drive far more concurrent virtual users than a pure-PHP runner could — while you keep writing tests in the language you already use.

That first script uses a constant load profile: a fixed number of virtual users, held for a fixed duration. It answers "does 50 users hurt?" — a load test question. To find the knee you need the load to keep climbing, which means stages:

find-the-knee.php
<?php
require 'vendor/autoload.php';
 
use VoltTest\VoltTest;
 
$voltTest = new VoltTest('Find the Knee');
 
// Each stage ramps linearly from the previous stage's target.
// Stages are mutually exclusive with setVirtualUsers/setDuration/setRampUp —
// calling both throws, because two load profiles can't describe one test.
$voltTest->stage('30s', 25)     // baseline: what does "healthy" look like?
    ->stage('1m', 100)
    ->stage('1m', 250)
    ->stage('1m', 500)
    ->stage('30s', 0);          // ramp down — recovery is data too
 
$scenario = $voltTest->scenario('Browse Homepage');
$scenario->step('Load homepage')
    ->get('https://staging.example.com/')
    ->validateStatus('Homepage OK', 200);
 
$voltTest->run(true);

Start the ladder at a load you're confident is healthy. Without that baseline you get a breaking point with nothing to compare it against, and no way to tell whether P95 at 250 VUs is a problem or just Tuesday.

Stress Testing a Laravel Application

If you're on Laravel, the dedicated package builds on the core SDK and adds Laravel-specific conveniences: Artisan commands, automatic route discovery, and CSRF/cookie handling. Install it as a dev dependency:

composer require volt-test/laravel-performance-testing --dev
php artisan vendor:publish --tag=volttest-config

Scaffold a test with Artisan:

php artisan volttest:make LoginTest

Then define the scenario. Note extractCsrfToken() — the package pulls Laravel's CSRF token out of the response automatically, so you don't have to:

app/VoltTests/LoginTest.php
<?php
 
namespace App\VoltTests;
 
use VoltTest\Laravel\Contracts\VoltTestCase;
use VoltTest\Laravel\VoltTestManager;
 
class LoginTest implements VoltTestCase
{
    public function define(VoltTestManager $manager): void
    {
        $manager->target('http://localhost:8000');
 
        $scenario = $manager->scenario('Login Flow');
 
        $scenario->step('Get Login Page')
            ->get('/login')
            ->expectStatus(200)
            ->extractCsrfToken();
 
        $scenario->step('Submit Login')
            ->post('/login', [
                'email' => 'user@example.com',
                'password' => 'password',
                '_token' => '${csrf_token}',
            ])
            ->expectStatus(302);
    }
}

The Laravel package also supports stages (ramp-up, hold, spike, ramp-down), CSV data sources for driving tests with realistic data, and a PHPUnit integration so your stress tests run inside your existing test suite with assertions like assertVTP95ResponseTime().

Everything in the bottlenecks section above applies to Laravel too, with a few framework-specific wrinkles: Eloquent makes N+1 amplification easy to write by accident, the default file session driver carries the same lock as plain PHP, and queue backlogs are usually watched through Horizon.

Three guides cover the Laravel workflow in depth:

Quick One-Off Stress Tests from the Command Line

Sometimes you don't want to write a test class at all — you just want to hammer one endpoint and see if it holds up, the way you would with ab -n 1000 -c 50. The Laravel package's volttest:run command does exactly that, with none of ab's limitations.

Stress test a single URL with 50 virtual users for one minute — the command auto-detects that the argument is a URL, so there's nothing else to configure:

php artisan volttest:run https://api.example.com/health --users=50 --duration=1m

You can also stress test authenticated POST endpoints with a request body inline:

php artisan volttest:run https://example.com/api/login \
    --users=100 \
    --method=POST \
    --body='{"email":"test@example.com","password":"secret"}'

Useful flags (run php artisan volttest:run --help for the full list):

FlagPurpose
--users=Number of virtual users
--duration=How long to run (e.g. 30s, 1m, 5m)
--method=HTTP method — GET, POST, PUT, DELETE
--body=Request body for POST/PUT
--headers=JSON string of request headers
--code-status=Expected HTTP status code (default 200)
--stage=Load stages as duration:target, e.g. --stage=1m:50 --stage=5m:100 --stage=1m:0
--cloudRun the test on VoltTest Cloud
--streamStream live output to the console

This gives you the one-liner speed of Apache Bench but with real virtual-user behavior, percentile metrics, ramp-up stages, and a path to cloud-scale execution — all from a single Artisan command.

Reading Your Stress Test Results

A stress test is only useful if you can interpret the report. VoltTest output looks like this:

Performance Report: Homepage Stress Test
----------------------------------------------------------------------
Total Requests:      5000
Success Rate:        99.94%
Requests/Sec (RPS):  346.19
Avg Latency:         74.24ms
P95 Latency:         128.71ms
P99 Latency:         210.05ms
----------------------------------------------------------------------

What to focus on:

  • Success Rate — anything below ~99% under load usually means the app is dropping requests or erroring out.
  • Requests/Sec (RPS) — your real-world throughput ceiling.
  • Avg Latency — useful, but it hides outliers. Don't ship on the average alone.
  • P95 / P99 Latency — the latency 95% and 99% of users experience. This is where real problems surface: an average of 74ms with a P99 of 2,000ms means 1 in 100 users is having a terrible time.

The Saturation Signature

A single run gives you a snapshot. Running the same scenario at increasing virtual-user counts and lining the numbers up gives you the shape — which is what you actually came for:

VUsRPSP95P99Errors
50340129ms210ms0%
100670141ms233ms0%
200690402ms1,180ms0%
4006951,340ms4,900ms2.1%

Throughput stops growing between 100 and 200 virtual users. That's the knee, and this system's real capacity is around 100 concurrent users — not 400. Everything past the knee is queueing: the same ~690 requests per second delivered to twice as many people at three times the latency. The errors at 400 VUs are the breaking point, but the user-visible damage started 300 virtual users earlier, while the success rate still read 100%.

Note also that P99 degrades much faster than P95 across those rows — the long-tail pattern from the database section above.

Reading the Errors

When requests do start failing, the status code narrows the search considerably:

What you seeWhere to look first
502 Bad GatewayPHP-FPM pool exhausted, or a worker died — check the FPM error log
504 Gateway TimeoutA request outran nginx's fastcgi_read_timeout — usually a slow query or a blocking external API call
500 with SQLSTATEDatabase connection limit, lock wait timeout, or a deadlock
Connection reset / refusedYou hit an OS or load-balancer limit before reaching PHP — somaxconn, file descriptors, ephemeral ports
Timeouts reported only by the clientFrequently the load generator itself, not the target

That last row is worth taking seriously. A load generator saturating its own CPU reports latency that belongs to the test machine. Check the generator's resource usage before you go looking for the bottleneck in your application — VoltTest's Go engine keeps per-VU overhead low precisely so this happens later, but "later" is not "never".

Common PHP Stress Testing Mistakes

A few mistakes make stress test results misleading:

  • Testing localhost — your laptop is not production. Network, CPU, and database differ wildly. Test against a staging environment that mirrors production.
  • No ramp-up — slamming the app with all users at once measures a thundering herd, not real traffic. Use stages to ramp up gradually.
  • Ignoring P99 — averages look fine while a slice of users suffer. Always read the tail latencies.
  • Stress testing production unannounced — you can take down a live system. Coordinate with your team, and prefer staging or a clearly-marked maintenance window.
  • No baseline run — a breaking point on its own is a number with nothing to compare it to. Establish the healthy P95 at low load first, then climb.
  • Hammering one endpoint — a single URL rarely exercises the resource you'll actually run out of. Real traffic mixes cheap and expensive routes and shares a session, and the interaction between them is where the bottleneck usually lives.
  • Only ever testing a warm cache — if you never test a cold start, you never test the scenario that follows every deploy.

Run Large-Scale Stress Tests with VoltTest Cloud

Running stress tests from one machine works well up to a point. To simulate hundreds of thousands of concurrent users from multiple regions, you need distributed infrastructure — and that's what VoltTest Cloud provides, without you managing a single server.

Conclusion

Stress testing tells you how your PHP application behaves under pressure before your users find out the hard way. Most of the value isn't in the breaking point — it's in learning which of the five bottlenecks you hit first, because that's the one worth spending a sprint on.

The right tool removes the friction: with VoltTest you write tests in PHP, install via Composer, run them against plain PHP or Laravel, and read real percentile metrics — all on a high-performance Go engine. Start with a baseline run on staging, add stages until throughput plateaus, note where the knee lands, and wire the test into CI so the next regression shows up in a pull request instead of in production.

Learn More


Star the repository on GitHub: volt-test/php-sdk

💬 Follow updates on X: @VoltTest


Ready to push your limits?

Start with 500 VUs, real cloud infrastructure, and real metrics — no credit card required. Upgrade only when you outgrow it.

Free forever — 500 VUs, 10-minute runs, no credit card