☁️VoltTest Cloud closed beta is now openJoin the waitlist
Skip to main content

How to Load Test Laravel Applications: Tools, Setup, and Real Results

· 17 min read
Islam A-Elwafa
Software Engineer

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.

TL;DR

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.

Load test Laravel in pure PHP with VoltTest: CSRF, auth flows and queues handled, with PHPUnit gates for CI/CD

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 foreach loop 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 WHERE clause 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_connections limit, 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:

FeatureVoltTestk6 (Grafana)Pest StresslessLoadForge
Test languagePHPJavaScriptPHP (wraps k6)Python (Locust)
Installcomposer requireBinary downloadcomposer requireSaaS / pip
Laravel CSRF handlingBuilt-in extractCsrfToken()Manual JS parsingNoManual Python parsing
Cookie/session managementautoHandleCookies()ManualNoManual
Multi-step scenariosYes (chained steps with data extraction)YesNo (single URL only)Yes
Artisan commandsvolttest:make, volttest:runN/Apest stressN/A
PHPUnit integrationNative (PerformanceTestCase + assertions)NoVia Pest expectationsNo
Route discovery--routes flag auto-generates testsNoNoNo
Data-driven (CSV)Yes (unique, sequential, random modes)YesNoYes
Cloud scalingBuilt-in (30+ regions)Grafana Cloud k6NoBuilt-in
P95/P99 metricsYesYesYes (via k6)Yes
Staged load profilesYes (spike, stress, soak patterns)YesNo (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:

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');

// 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.

app/VoltTests/OrderLoadTest.php
<?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:

MetricWhat It Tells YouWhen to Worry
Success RatePercentage of non-error responsesBelow 99% under expected load
Req/sec (RPS)Actual throughputBelow your expected peak traffic
P9595% of users experience this latency or betterAbove 200ms for API routes, 500ms for web pages
P99The worst 1% of user experiencesMore than 3x the P95 (indicates intermittent bottlenecks)
AvgStatistical average that hides outliersAvoid making decisions on this alone
MaxSingle worst responseAbove 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 TypeTarget P95Target Success RateNotes
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

tests/Performance/ApiLoadTest.php
<?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:

AssertionWhat 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:

.github/workflows/load-test.yml
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.

Closed Beta

VoltTest Cloud is in closed beta. Sign up for access:

CLOSED BETA

Join Early Access

Sign up to join the waitlist. We review and approve access in waves.

VoltTest is currently in closed beta. After signing up, your account will be reviewed and you'll be notified once approved.
Join Waitlist →Already have an account? Sign in

What to Do Next

  1. Install the package: composer require volt-test/laravel-performance-testing --dev
  2. Test your login flow: it's the most common bottleneck and the easiest to test first
  3. Run at increasing VU counts: 10, 50, 100, 200. Watch where P95 inflects
  4. Add PHPUnit assertions: wire performance tests into your CI pipeline so regressions never reach production
  5. 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


GitHub: volt-test/php-sdk | volt-test/laravel-performance-testing

Follow updates on X: @VT_Developers