How to Load Test Laravel Applications: Tools, Setup, and Real Results
More than seven years ago, I shipped a Laravel API that passed every unit test, every feature test, and every browser test I threw at it. The staging environment ran fine. Code review was clean. I deployed on a Friday afternoon, and by Monday morning, the app was returning 502s under 200 concurrent users.
The database connection pool was exhausted. File-based sessions were creating lock contention. A queued job that ran fine in isolation was stacking up 4,000 pending jobs because the worker couldn't keep pace with incoming requests. None of these problems showed up in testing because I'd never tested with more than one user at a time.
The company's first answer was to hire an external firm to run the load tests. The results were useful, but they arrived as a report from people who didn't know our codebase, and we couldn't rerun them after every change. So we brought load testing in-house with tools like k6, and got familiar with them ourselves. That exposed the real cost: the tooling lived outside PHP, so every new engineer we hired had to learn a separate language and toolchain before they could write a single test. With VoltTest, a PHP team goes from zero to a working load test in about two hours at most, because there's nothing new to learn.
That experience is why I built VoltTest, and it's why I'm writing this guide. Laravel load testing isn't optional if you're running anything beyond a personal project. This guide covers why Laravel apps fail under load, how the available tools compare, and how to set up and run real load tests using PHP: no JavaScript, no Python, no context-switching.
Laravel load testing catches failures that unit and feature tests miss: database bottlenecks, session contention, queue backlogs. VoltTest lets you write load tests in PHP, handles CSRF and cookies automatically, integrates with PHPUnit for CI/CD gating, and scales from local runs to thousands of concurrent users in the cloud. This guide walks through the complete setup.

What Breaks in Laravel Under Load
Laravel is optimized for developer productivity, not raw throughput. That's a deliberate trade-off, and a fine one, as long as you know where the framework's abstractions become bottlenecks under concurrent traffic.
Laravel performance testing reveals bottlenecks that are invisible at low traffic. Here's what I've seen break in production, in order of how often it catches people off guard:
Database: The First Wall You Hit
Eloquent makes database access effortless, which is exactly why it becomes a problem under load. The patterns that work fine for 10 users collapse at 500:
- N+1 queries: a
foreachloop that fires 200 individual SELECTs instead of one eager-loaded query. At 100 concurrent users, that's 20,000 queries per second hitting your database. - Missing indexes: a
WHEREclause on an unindexed column scans the full table. Fast at 1,000 rows, catastrophic at 1,000,000. - Connection pool exhaustion: PHP opens and closes database connections per-request. Under sustained load, the connection churn can exceed your database's
max_connectionslimit, causing new requests to queue or fail.
Sessions and Authentication
File-based sessions (Laravel's default) use file locks. When multiple requests hit the same session (AJAX-heavy pages, SPA polling), they serialize instead of running in parallel. I've seen P95 response times jump from 50ms to 3 seconds just from session lock contention.
CSRF token verification adds per-request overhead that's invisible at low traffic but measurable at scale. Sanctum token lookups hit the database on every authenticated request. That's fine at 50 RPS, but potentially a bottleneck at 500.
Queues and Jobs
Your queue workers have a fixed throughput. If incoming jobs arrive faster than workers can process them, the queue grows indefinitely. Load testing reveals whether your worker count matches your actual job generation rate, something you can't determine from unit tests.
I've seen a notification system that dispatched 3 jobs per user action (email, Slack, database notification) overwhelm a 2-worker Horizon setup at just 100 concurrent users. The fix was simple (more workers, batching), but the problem only surfaced under load.
PHP-FPM Worker Saturation
Each PHP-FPM worker handles one request at a time. When all workers are busy, new requests queue at the web server level. The default pm.max_children = 5 on many development setups is nowhere near enough for production traffic. Load testing tells you exactly how many workers you need, and whether switching to Laravel Octane with its persistent workers would help.
Laravel Load Testing Tools Compared
There's no shortage of load testing tools, but most of them aren't built with PHP or Laravel in mind. Here's an honest comparison of the options a Laravel developer is likely to consider:
| Feature | VoltTest | k6 (Grafana) | Pest Stressless | LoadForge |
|---|---|---|---|---|
| Test language | PHP | JavaScript | PHP (wraps k6) | Python (Locust) |
| Install | composer require | Binary download | composer require | SaaS / pip |
| Laravel CSRF handling | Built-in extractCsrfToken() | Manual JS parsing | No | Manual Python parsing |
| Cookie/session management | autoHandleCookies() | Manual | No | Manual |
| Multi-step scenarios | Yes (chained steps with data extraction) | Yes | No (single URL only) | Yes |
| Artisan commands | volttest:make, volttest:run | N/A | pest stress | N/A |
| PHPUnit integration | Native (PerformanceTestCase + assertions) | No | Via Pest expectations | No |
| Route discovery | --routes flag auto-generates tests | No | No | No |
| Data-driven (CSV) | Yes (unique, sequential, random modes) | Yes | No | Yes |
| Cloud scaling | Built-in (30+ regions) | Grafana Cloud k6 | No | Built-in |
| P95/P99 metrics | Yes | Yes | Yes (via k6) | Yes |
| Staged load profiles | Yes (spike, stress, soak patterns) | Yes | No (fixed concurrency) | Yes |
When to Use Each Tool
VoltTest is the right choice when your team writes PHP, you want tests version-controlled alongside your Laravel codebase, and you need multi-step scenarios with CSRF/auth handling without leaving the PHP ecosystem. It's the only tool with native Laravel Artisan integration and PHPUnit assertions for CI/CD gating.
k6 is excellent if your team is already invested in the Grafana observability stack or prefers JavaScript. Its Go-based engine is fast, and Grafana Cloud k6 scales well. Laravel.com benchmarked it at 17,000 RPS against Laravel Cloud. But you'll write tests in JavaScript, not PHP.
Pest Stressless is perfect for quick single-URL smoke tests (./vendor/bin/pest stress example.com --concurrency=5). It wraps k6 under the hood and integrates with Pest's expectation API. But it can't chain requests, handle CSRF tokens, or simulate multi-step user flows. It's a quick-check tool, not a load testing solution.
LoadForge works well for teams comfortable with Python/Locust who want a managed cloud platform. Its Laravel-specific guides are solid, but you write tests in Python.
Step-by-Step: Load Testing Laravel with VoltTest
Let me walk through how I set up load testing for a Laravel application, from install to results.
Install the Laravel Package
composer require volt-test/laravel-performance-testing --dev
php artisan vendor:publish --tag=volttest-config
This gives you Artisan commands, automatic CSRF handling, cookie management, and PHPUnit integration. The core VoltTest PHP SDK is pulled in as a dependency.
Create Your First Test
php artisan volttest:make LoginTest
This scaffolds app/VoltTests/LoginTest.php. Here's a complete login flow that handles CSRF tokens automatically:
<?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');
// Step 1: Load login page, extract CSRF token
$scenario->step('Get Login Page')
->get('/login')
->expectStatus(200)
->extractCsrfToken();
// Step 2: Submit login with extracted token
// Cookies are handled automatically, no manual session management
$scenario->step('Submit Login')
->post('/login', [
'_token' => '${csrf_token}',
'email' => 'user@example.com',
'password' => 'password',
])
->expectStatus(302)
->thinkTime('1s');
// Step 3: Verify authenticated access
$scenario->step('Load Dashboard')
->get('/dashboard')
->expectStatus(200);
}
}
extractCsrfToken() reads the _token hidden input from the HTML response and stores it as ${csrf_token} for subsequent steps. Cookies, including the laravel_session cookie, are passed between steps automatically. No manual header juggling.
Run the Test
php artisan volttest:run LoginTest --users=50 --duration=1m
For more control over how users are added over time, use staged load profiles:
php artisan volttest:run LoginTest --users=50 --duration=1m --ramp-up=15s
Or define stages directly in your test for spike or stress patterns:
public function define(VoltTestManager $manager): void
{
$manager->target('http://localhost:8000');
// Ramp up, hold, spike, recover, ramp down
$manager->stage('1m', 50); // Ramp to 50 VUs
$manager->stage('5m', 50); // Hold steady
$manager->stage('10s', 200); // Spike to 200
$manager->stage('2m', 200); // Hold spike
$manager->stage('1m', 0); // Ramp down
$scenario = $manager->scenario('Login Flow');
// ... steps as above
}
See the load profiles guide for spike, stress, soak, and step-up patterns.
Testing Authentication Flows Under Load
Authentication is where most load testing tools struggle with Laravel. CSRF tokens, session cookies, and Sanctum tokens all require extracting values from responses and passing them to subsequent requests. VoltTest handles both web and API authentication patterns.
Web Routes: CSRF + Session Cookies
For traditional Laravel web routes (Blade forms, Inertia, Livewire), VoltTest automatically handles the laravel_session cookie and provides extractCsrfToken():
$scenario = $manager->scenario('Registration Flow')
->dataSource('users.csv', 'unique');
$scenario->step('Registration Page')
->get('/register')
->expectStatus(200)
->extractCsrfToken()
->thinkTime('3s');
$scenario->step('Submit Registration')
->post('/register', [
'_token' => '${csrf_token}',
'name' => '${name}',
'email' => '${email}',
'password' => '${password}',
'password_confirmation' => '${password}',
])
->expectStatus(302);
$scenario->step('Verify Dashboard Access')
->get('/dashboard')
->expectStatus(200);
Each virtual user gets unique credentials from the CSV file, so sessions don't collide.
API Routes: Sanctum Token Auth
For API endpoints using Laravel Sanctum or Passport, extract the token from the JSON response:
$scenario = $manager->scenario('API Auth Flow');
$scenario->step('Login API')
->post('/api/login', [
'email' => 'user@example.com',
'password' => 'password',
], ['Content-Type' => 'application/json', 'Accept' => 'application/json'])
->expectStatus(200)
->extractJson('token', 'data.token');
$scenario->step('Get User Profile')
->get('/api/user')
->header('Authorization', 'Bearer ${token}')
->header('Accept', 'application/json')
->expectStatus(200)
->extractJson('user_id', 'data.id');
$scenario->step('Update Profile')
->put('/api/user/${user_id}', [
'name' => 'Updated Name',
], ['Content-Type' => 'application/json', 'Authorization' => 'Bearer ${token}'])
->expectStatus(200);
The extractJson('token', 'data.token') call captures the token from the login response and makes it available as ${token} in all subsequent steps. This works with any JSON structure: nested paths like data.user.api_token and array indexing like data[0].token are both supported.
Testing Queues, Jobs, and Broadcasting Under Load
Authentication isn't the only Laravel-specific concern. Queue-heavy applications (and most non-trivial Laravel apps dispatch jobs) need load testing that exercises the full request-to-job pipeline.
The pattern is straightforward: your load test hits endpoints that dispatch jobs, and you monitor whether the queue keeps up or falls behind. VoltTest doesn't test queue workers directly (that's a monitoring concern; Laravel Horizon and Pulse handle it). But it does tell you how fast your application generates jobs under concurrent load, which is the data you need to size your worker pool.
Here's a real scenario: an e-commerce app where every order dispatches three jobs: a confirmation email, an inventory update, and a webhook notification to a fulfillment service.
<?php
namespace App\VoltTests;
use VoltTest\Laravel\Contracts\VoltTestCase;
use VoltTest\Laravel\VoltTestManager;
class OrderLoadTest implements VoltTestCase
{
public function define(VoltTestManager $manager): void
{
$manager->target('http://localhost:8000');
$scenario = $manager->scenario('Order Flow')
->dataSource('customers.csv', 'unique');
$scenario->step('Login API')
->post('/api/login', [
'email' => '${email}',
'password' => '${password}',
], ['Content-Type' => 'application/json'])
->expectStatus(200)
->extractJson('token', 'data.token');
$scenario->step('Add to Cart')
->post('/api/cart', [
'product_id' => 42,
'quantity' => 1,
], [
'Authorization' => 'Bearer ${token}',
'Content-Type' => 'application/json',
])
->expectStatus(200)
->thinkTime('2s');
$scenario->step('Place Order')
->post('/api/orders', [
'payment_method' => 'card_test',
'shipping_address_id' => 1,
], [
'Authorization' => 'Bearer ${token}',
'Content-Type' => 'application/json',
])
->expectStatus(201);
}
}
Run this at 100 concurrent users for 5 minutes and then check Horizon: how many pending jobs accumulated? If the queue depth grew steadily throughout the test, your workers can't keep up with the job generation rate at that concurrency level. The fix is usually more workers, job batching, or moving non-critical jobs (analytics, logging) to a separate lower-priority queue.
This is the kind of problem that never appears in unit or feature tests. It only surfaces when the rate of job dispatch exceeds worker throughput, which requires concurrent load to trigger.
Interpreting Your Results: P95, P99, and What They Mean
When I run a load test, the output looks like this:
Test Metrics Summary:
===================
Duration: 60.003s
Total Reqs: 12847
Success Rate: 99.92%
Req/sec: 214.11
Success Requests: 12837
Failed Requests: 10
Response Time:
------------
Min: 8.21ms
Max: 1.247s
Avg: 94.31ms
Median: 72.18ms
P95: 182.65ms
P99: 341.20ms
Here's what matters and what doesn't:
| Metric | What It Tells You | When to Worry |
|---|---|---|
| Success Rate | Percentage of non-error responses | Below 99% under expected load |
| Req/sec (RPS) | Actual throughput | Below your expected peak traffic |
| P95 | 95% of users experience this latency or better | Above 200ms for API routes, 500ms for web pages |
| P99 | The worst 1% of user experiences | More than 3x the P95 (indicates intermittent bottlenecks) |
| Avg | Statistical average that hides outliers | Avoid making decisions on this alone |
| Max | Single worst response | Above 5 seconds (timeouts incoming) |
The P95/P99 gap is your most important diagnostic signal. If P95 is 180ms but P99 is 2 seconds, you have intermittent slowdowns, usually database lock contention, garbage collection pauses, or a slow external API call that occasionally blocks. If P95 and P99 are close together, your performance is consistent.
Run at increasing VU counts (50, 100, 200, 500) and plot P95 against VU count. The inflection point, where P95 starts climbing sharply, is your app's capacity ceiling. Everything above that line requires optimization or horizontal scaling.
What "Good" Looks Like for Laravel
These aren't universal rules; your thresholds depend on app complexity, database size, and infrastructure. But as starting benchmarks from my experience testing dozens of Laravel apps:
| Endpoint Type | Target P95 | Target Success Rate | Notes |
|---|---|---|---|
| Static/cached API | < 50ms | > 99.9% | Redis or response cache should absorb most of the work |
| Authenticated API (CRUD) | < 200ms | > 99.5% | Database queries are the usual bottleneck |
| Web pages (Blade/Inertia) | < 500ms | > 99% | Includes view rendering and asset pipeline overhead |
| Complex operations (reports, exports) | < 2s | > 95% | Heavy queries are expected, so set realistic thresholds |
If your numbers are significantly worse than these baselines under moderate load (50-100 VUs), you likely have a query optimization issue, not an infrastructure one. Start with php artisan telescope or query logging before scaling hardware.
Automating Load Tests in CI/CD
Performance regressions are silent: they don't trigger test failures unless you wire up assertions. VoltTest's PHPUnit integration turns load tests into automated quality gates.
PHPUnit Performance Assertions
<?php
namespace Tests\Performance;
use App\VoltTests\LoginTest;
use VoltTest\Laravel\Testing\PerformanceTestCase;
class ApiLoadTest extends PerformanceTestCase
{
public function test_login_flow_under_load(): void
{
$result = $this->runVoltTest(new LoginTest(), [
'virtual_users' => 30,
'duration' => '30s',
]);
// Fail the build if performance degrades
$this->assertVTSuccessful($result, 99.0);
$this->assertVTP95ResponseTime($result, 200);
$this->assertVTP99ResponseTime($result, 500);
$this->assertVTMinimumRPS($result, 100);
$this->assertVTErrorRate($result, 1.0);
}
}
Available assertions:
| Assertion | What It Checks |
|---|---|
assertVTSuccessful($result, 99.0) | Success rate ≥ 99% |
assertVTP95ResponseTime($result, 200) | P95 latency ≤ 200ms |
assertVTP99ResponseTime($result, 500) | P99 latency ≤ 500ms |
assertVTMinimumRPS($result, 100) | Throughput ≥ 100 req/s |
assertVTErrorRate($result, 1.0) | Error rate ≤ 1% |
assertVTAverageResponseTime($result, 150) | Avg latency ≤ 150ms |
assertVTMedianResponseTime($result, 100) | Median latency ≤ 100ms |
GitHub Actions Workflow
Here's a working workflow that runs load tests on every pull request and fails the build on performance regression:
name: Performance Tests
on:
pull_request:
branches: [main]
jobs:
load-test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_DATABASE: testing
MYSQL_ROOT_PASSWORD: password
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
redis:
image: redis:7
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: pdo_mysql, redis, pcntl
coverage: none
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: Prepare environment
run: |
cp .env.testing .env
php artisan key:generate
php artisan migrate --seed
- name: Start application server
run: php artisan serve --host=127.0.0.1 --port=8000 &
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_DATABASE: testing
DB_USERNAME: root
DB_PASSWORD: password
- name: Wait for server
run: |
for i in $(seq 1 30); do
curl -s http://127.0.0.1:8000 > /dev/null && break
sleep 1
done
- name: Run performance tests
run: vendor/bin/phpunit --testsuite=Performance
env:
VOLTTEST_BASE_URL: http://127.0.0.1:8000
Add the performance test suite to your phpunit.xml:
<testsuite name="Performance">
<directory>tests/Performance</directory>
</testsuite>
Now every PR runs a load test. If P95 latency exceeds 200ms or the success rate drops below 99%, the build fails, and the developer sees exactly which metric regressed.
Scaling to Thousands of Users with VoltTest Cloud
Local testing is limited by your machine's hardware. My laptop can drive a few hundred virtual users comfortably; beyond that, the load generator itself becomes the bottleneck. VoltTest Cloud runs your tests on managed infrastructure across 30+ AWS regions.
The code doesn't change. You add one line:
$manager->cloud();
Or from the CLI:
php artisan volttest:run LoginTest --users=5000 --duration=5m --cloud
For multi-region distribution:
$manager->cloud();
$manager->regions([
'us-east-1' => 60, // 60% of VUs in Virginia
'eu-west-1' => 40, // 40% in Ireland
]);
The cloud dashboard shows real-time metrics (RPS, latency percentiles, error rates, and per-step breakdowns) as the test runs. Results are stored for comparison across test runs.
VoltTest Cloud is in closed beta. Sign up for access:
Join Early Access
Sign up to join the waitlist. We review and approve access in waves.
What to Do Next
- Install the package:
composer require volt-test/laravel-performance-testing --dev - Test your login flow: it's the most common bottleneck and the easiest to test first
- Run at increasing VU counts: 10, 50, 100, 200. Watch where P95 inflects
- Add PHPUnit assertions: wire performance tests into your CI pipeline so regressions never reach production
- Try cloud mode: when you need thousands of concurrent users or multi-region testing
Laravel performance testing isn't about generating impressive throughput numbers. It's about knowing, before your users tell you, whether your app handles the traffic you expect.
Learn More
- Laravel Package Documentation →
- VoltTest PHP SDK Documentation →
- Load Testing Laravel with PHPUnit (deep dive) →
- Load Profiles: Spike, Stress, Soak Patterns →
- PHP Load Testing Guide →
GitHub: volt-test/php-sdk | volt-test/laravel-performance-testing
Follow updates on X: @VT_Developers
