·26 min read
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 Load Test Symfony Applications: FrankenPHP vs PHP-FPM With Real Results
Most Symfony load testing advice comes in two flavours. The first is a checklist: tune Doctrine, cache more, watch PHP-FPM. The second is a benchmark of a controller that returns {"hello":"world"}, which is how FrankenPHP got its famous 3x headline. Neither tells you what your app does when 2,000 people log in, add the same product to their cart and press checkout at the same time.
So I built a Symfony 7.4 shop the way I would ship one: form login with CSRF, a JWT API, Doctrine on PostgreSQL, Redis sessions and a real checkout transaction. I load tested it in pure PHP with VoltTest. Then I ran the identical test against the identical app on nginx and php-fpm, and again with a cheaper password hash. Every number in this guide changes exactly one variable at a time.
This is the Symfony load testing companion to my Laravel load testing guide and part of the wider PHP load testing series. It covers what breaks in Symfony under load, how the tools compare, how to write the test in PHP without leaving your stack, what the numbers were, and one experiment that proves a checkout cannot oversell.

What Breaks in Symfony Under Load
Symfony is unusually well behaved under load compared with most PHP frameworks. The compiled container, opcache preload and a warm cache mean the framework itself is rarely the bottleneck. Symfony performance problems under concurrency almost always live in the five places below, and a Symfony load test is the only kind of test that reaches them.
Doctrine and the hot row
Everyone knows about N+1 queries, and the profiler catches them at one user. The failure that only shows up under load is contention. A checkout that decrements stock touches one row per product, and when 500 buyers want the same product, PostgreSQL serialises them on that row. Each transaction holds the lock for as long as it runs. The time you spend inside it, hydrating entities and flushing the order, becomes queueing time for everyone behind you. Later in this guide there is a flash sale experiment that measures exactly this.
Sessions: file locking versus Redis
Symfony's default session handler writes to the filesystem, and PHP locks the session file for the whole request. One user opening three tabs is fine. Two hundred users behind a load balancer with a shared volume is a lock convoy. Moving sessions to Redis with the built-in RedisSessionHandler removes the lock and lets the app run on more than one machine, which is why the test app uses it from the start. Removing the lock is a trade-off, not a free win: Symfony's handler does no session locking at all, so two concurrent requests that both write to the same session, such as parallel AJAX calls, can overwrite each other, and the usual symptom is a spurious "Invalid CSRF token". If your app writes to the session from concurrent requests, use the phpredis extension's native handler with redis.session.locking_enabled = 1 instead, and accept that requests from the same user serialise again.
Password hashing is CPU, not I/O
This is the one nobody budgets for. Symfony's auto password hasher currently means bcrypt, and in this app it resolved to cost 13 (every stored hash starts with $2y$13$). Argon2id only comes into play if you configure the sodium hasher explicitly, and it has a cost of its own. On my laptop one login costs about 365 ms of pure CPU and a product page about 2 ms. On the 2-core Fly machine used for the cloud runs a bcrypt verify costs 479 ms. That caps the box at roughly 4 logins per second no matter how fast the rest of the stack is. If your load test logs in on every iteration, and most do, login is your throughput ceiling. The numbers section shows what happens when the cost drops to 10.
CSRF and the stateless trap
Recent Symfony recipes enable stateless CSRF tokens for login, logout and form submits. The hidden field holds a placeholder that a Stimulus controller fills client-side, and without JavaScript the check silently falls back to Origin and Referer headers. That is fine for browsers and invisible to a load test, which then passes without ever exercising your session layer. The test app switches back to session-backed tokens, which is what most deployed Symfony apps still run. So the test has to fetch the form, extract the token and hold the session cookie like a real client.
php-fpm worker saturation versus worker mode
Under php-fpm, every request boots the kernel, and when requests outnumber pm.max_children they queue in nginx. FrankenPHP worker mode boots Symfony once per worker thread and handles requests in a loop, which removes the bootstrap cost entirely. The published benchmarks put that at 3x. Whether you get 3x depends on how much of your request was bootstrap in the first place, which is the question this guide's comparison answers.
Symfony Load Testing Tools Compared
Any HTTP load generator can hit a Symfony app, so the Symfony load testing tools question is really about ergonomics. The differences that matter for Symfony are whether the tool handles CSRF and session cookies without a fight, whether your team has to learn another language to use it, and how far it scales.
| Tool | Test language | CSRF and sessions | CI integration | Scale | Fit for Symfony teams |
|---|---|---|---|---|---|
| VoltTest | PHP | Cookie jar per virtual user, CSS-selector extractors for tokens | PHPUnit assertions, exit codes | Local runs, distributed multi-region runs in the cloud | Native |
| k6 | JavaScript | Automatic per-VU cookie jar; CSRF extraction and correlation written in JavaScript | Excellent | High, cloud is paid | Good if the team already writes JS |
| JMeter | XML via GUI | Regex or CSS extractors, cookie manager | Possible, clunky | High with distributed setup | Heavy, but everywhere |
| Gatling | Java, Kotlin, Scala, JavaScript or TypeScript DSL | Good | Good | High | Rare in PHP shops |
| LoadForge | Python (Locust) | Manual | Yes | Cloud only | Another language and a hosted dependency |
ab, wrk | Command line | None | Trivial | Single machine | Smoke tests of one URL only |
When to use each: wrk for a ten-second sanity check of a single endpoint; k6 or Gatling if the people writing the tests are not PHP developers; JMeter if compliance demands it; VoltTest if you want the test to live in the same repository, language and CI pipeline as the Symfony app it protects. The rest of this guide uses VoltTest, and the disclosure at the top of the page applies. I built it.
The Test App: a Production-Mode Symfony Shop
Load testing a hello world measures the runtime. Load testing a real Symfony app measures your architecture. The target here is a small shop, deliberately configured the way a production Symfony app would be, not the way a dev container is.
| Piece | Choice | Why it matters under load |
|---|---|---|
| Framework | Symfony 7.4 LTS on PHP 8.4 | The current LTS. FrankenPHP worker mode is natively supported in Symfony 7.4 and later, so no runtime bridge package is needed (the repo still lists runtime/frankenphp-symfony in composer.json, but with no APP_RUNTIME set it is inert and the native runtime served every run) |
| Runtime | FrankenPHP worker mode with opcache preload of the compiled container | Kernel boots once per worker, nothing is re-parsed |
| Database | PostgreSQL through Doctrine ORM (16 in the local Compose stack, 18 on Fly for the cloud runs) | Stock is reserved with a conditional UPDATE inside the checkout transaction |
| Sessions | Redis via RedisSessionHandler | No file locks, works on more than one machine; no session locking either, see above |
| API auth | json_login plus LexikJWTAuthenticationBundle on a stateless firewall | Bearer tokens, no session on /api/* |
| Web auth | form_login with session CSRF, Form component CSRF on add-to-cart | The realistic path a browser takes |
Two design rules make it load testable. Every failure returns a real status code, because a load test asserts on status: invalid form 422, bad CSRF on checkout 403, empty cart or sold out 409, bad JWT 401. Symfony re-renders an invalid form with 200 by default, which would make a broken test look green. And the catalog has a flash-sale product with exactly 500 units so that oversell can be measured rather than assumed.
The app exposes the same shop twice: a JSON API under /api/v1 (login, products, cart, checkout) and server-rendered pages (/login, /products/{id}, /cart, /checkout). The whole thing, load test included, is at volt-test/symfony-load-testing-example.
Step-by-Step: How to Load Test Symfony in PHP
The whole Symfony load test is one PHP file. It defines four scenarios that share a pool of virtual users, each fed from its own CSV of accounts, and it runs either on your laptop or in VoltTest Cloud without changing a line.
Install the SDK
mkdir loadtest && cd loadtest
composer require volt-test/php-sdkThe SDK downloads the load-generating engine, a single Go binary, on first run. Nothing else to install, no JVM, no Node.
The API scenario: JWT login, products, cart
use VoltTest\DataSourceConfiguration;
use VoltTest\VoltTest;
$base = rtrim(getenv('TARGET_URL') ?: 'http://localhost:8088', '/');
$test = (new VoltTest('Symfony Shop'))
->target($base)
->setVirtualUsers((int) (getenv('VUS') ?: 200))
->setDuration(getenv('DURATION') ?: '2m')
->setRampUp('30s');
$users = fn (string $shard) => new DataSourceConfiguration(__DIR__."/data/users-$shard.csv", 'unique', true);
$browse = $test->scenario('API: browse and add to cart')
->setWeight(50)
->setThinkTime('1s')
->setDataSourceConfiguration($users('1'));
$browse->step('Login (JWT)')
->post("$base/api/v1/login", '{"email":"${email}","password":"${password}"}')
->header('Content-Type', 'application/json')
->validateStatus('login ok', 200)
->extractFromJson('token', 'token');
$browse->step('Product detail')
->get("$base/api/v1/products/\${product_id}")
->validateStatus('product ok', 200);
$browse->step('Add to cart')
->post("$base/api/v1/cart/items", '{"product_id":${product_id},"quantity":${quantity}}')
->header('Content-Type', 'application/json')
->header('Authorization', 'Bearer ${token}')
->validateStatus('cart item created', 201);extractFromJson('token', 'token') reads Lexik's {"token": "..."} response into a variable, and ${token} interpolates it into the Authorization header of every later step. The ${email}, ${product_id} and ${quantity} variables come from the CSV row that virtual user was handed.
The HTML scenario: CSRF, sessions and why it logs out
This is the scenario that breaks most Symfony load tests on the first run, and it is the reason a Symfony load testing tool has to understand cookies. A browser gets the session cookie and the CSRF token for free. A load generator has to do what the browser does, in order.
$html = $test->scenario('HTML: login, add to cart, checkout')
->setWeight(30)
->setThinkTime('2s')
->autoHandleCookies()
->setDataSourceConfiguration($users('3'));
$html->step('Login page')
->get("$base/login")
->validateStatus('login page ok', 200)
->extractFromHtml('login_csrf', 'input[name="_csrf_token"]', 'value');
$html->step('Submit login form')
->post("$base/login", '_username=${email}&_password=${password}&_csrf_token=${login_csrf}')
->header('Content-Type', 'application/x-www-form-urlencoded')
->validateStatus('login redirect', 302);
$html->step('Cart page (proves the session)')
->get("$base/cart")
->validateStatus('logged-in cart ok', 200);
$html->step('Product page')
->get("$base/products/\${product_id}")
->extractFromHtml('form_csrf', 'input[name="add_to_cart[_token]"]', 'value');
$html->step('Add to cart form')
->post("$base/cart/add", 'add_to_cart[product_id]=${product_id}&add_to_cart[quantity]=${quantity}&add_to_cart[_token]=${form_csrf}')
->header('Content-Type', 'application/x-www-form-urlencoded')
->validateStatus('added, redirect to cart', 302);
$html->step('Cart page')
->get("$base/cart")
->extractFromHtml('checkout_csrf', 'form.checkout-form input[name="_token"]', 'value')
->extractFromHtml('logout_csrf', 'form[action="/logout"] input[name="_csrf_token"]', 'value');
$html->step('Checkout form')
->post("$base/checkout", '_token=${checkout_csrf}')
->header('Content-Type', 'application/x-www-form-urlencoded')
->validateStatus('order placed', 302);
$html->step('Logout')
->post("$base/logout", '_csrf_token=${logout_csrf}')
->header('Content-Type', 'application/x-www-form-urlencoded')
->validateStatus('logged out', 302);Four Symfony specifics are hiding in there:
- Two token names. The security component's login form uses
_csrf_tokenwith token idauthenticate. A Form-component form nests its token under the form name, so the add-to-cart field isadd_to_cart[_token].extractFromHtmltakes a CSS selector and an attribute, which is cleaner than the regex you would write in JMeter. - 302 on success and on failure. Symfony redirects to the target on a good login and back to
/loginon a bad one. Both are 302, so validating the login POST proves nothing. The next step requests/cart, which answers 200 with a session and 302 to/loginwithout one. That is the real assertion. - The engine never follows redirects. That is deliberate. Following them would hide the 302 you want to assert on and double-count requests.
- The scenario logs out. A virtual user keeps its cookie jar for the whole run. Without the logout step, the second iteration's
GET /loginanswers 302 because the user is still signed in, and the whole iteration cascades into failures. Because each VoltTest virtual user keeps its cookie jar across iterations, log out explicitly, or clear the cookies, whenever an iteration is supposed to start unauthenticated.
Data-driven users, and why each scenario gets its own shard
Each CSV row carries email,password,product_id,quantity, so traffic spreads across the catalog instead of hammering product number one. The rows are handed out in unique mode, one per iteration.
The first version of this test gave all scenarios the same CSV. Every run had a few percent of unexplained 409s and empty carts. The cause: the API checkout scenario and the HTML shopper both started at row one, so the same account was mid-flow in two scenarios at once, and one checkout emptied the other's cart. The fix is a console command that exports disjoint shards, one per scenario, from a pool of 30,000 seeded users. If your accounts are shared state, your scenarios must not share accounts.
php bin/console app:export-users loadtest/data/users.csv # writes users-1.csv, users-2.csv, users-3.csv, users-flash-sale.csvThe flash-sale scenario
$flash = $test->scenario('API: flash sale (oversell guard)')
->setWeight(15)
->setDataSourceConfiguration($users('flash-sale'));
$flash->step('Login (JWT)')
->post("$base/api/v1/login", '{"email":"${email}","password":"${password}"}')
->header('Content-Type', 'application/json')
->validateStatus('login ok', 200)
->extractFromJson('token', 'token');
$flash->step('Add flash-sale item')
->post("$base/api/v1/cart/items", '{"product_id":${product_id},"quantity":1}')
->header('Content-Type', 'application/json')
->header('Authorization', 'Bearer ${token}')
->validateStatus('cart item created', 201);
// No validateStatus on purpose: 201 until sold out, then 409 is the *correct* answer.
$flash->step('Checkout')
->post("$base/api/v1/checkout", '{}')
->header('Content-Type', 'application/json')
->header('Authorization', 'Bearer ${token}');Every row in that shard points at the one product with 500 units. Once it sells out, 409 is right, so the checkout step carries no status assertion. The pass/fail check lives in the database instead, and the flash sale section shows it.
Running It: Local First, Then VoltTest Cloud
Run the whole Symfony load test against Docker Compose before touching the cloud. A 30-second run at 20 virtual users finds every script bug, and it is free.
TARGET_URL=http://localhost:8088 VUS=20 DURATION=30s php symfony-shop-test.phpTotal Reqs: 405
Success Rate: 100.00%
Req/sec: 13.28
Median: 2.919ms P95: 380.159ms P99: 396.287msEven that tiny run tells a story: a 3 ms median with a 380 ms p95 is the login step, and nothing else, because bcrypt is the only slow thing in the app at 20 users.
The cloud run is the same file with an API key and a region split:
if ($apiKey = getenv('VOLTTEST_API_KEY')) {
$test->cloud($apiKey)->regions(['us-east-1' => 100]);
$run = $test->run();
echo "Dashboard: {$run->getDashboardUrl()}\n";
}TARGET_URL=https://your-symfony-app.fly.dev VOLTTEST_API_KEY=... STAGES="1m:50,1m:100,1m:200,1m:400,1m:800" php symfony-shop-test.phpThat staged ramp is the profile every result below was measured with; the script maps STAGES onto the SDK's stage() calls instead of setVirtualUsers(). For a constant load use VUS=200 DURATION=5m instead.
VoltTest provisions engines in the regions you name, runs the test, and streams results to a dashboard with per-scenario and per-step breakdowns. See the cloud mode docs for staged load profiles and multi-region splits.
Symfony Load Testing Results: Where a 2-Core Box Saturates
The first Symfony load test was the number everyone wants to quote: 2,000 users for five minutes. It produced nothing usable, with Fly's proxy capped at 1,000 concurrent requests in front of the app at the time (the published repository's fly.toml now sets the soft and hard limits to 2,000 and 4,000, the values every later run used): 93% of requests hit the engine's 30-second timeout, and every step's p50 was 30,015 ms, which is the timeout, not the app. A run that only reports timeouts tells you the number is too big, not what the number is. So the second run ramped instead, one minute at each of 50, 100, 200, 400 and 800 users, to find where the app actually bends.
| Stage | Users | Requests/s | Errors | p50 | p95 |
|---|---|---|---|---|---|
| 1 | 50 | 13.3 | 0% | 433 ms | 1.1 s |
| 2 | 100 | 19.5 | 0% | 2.2 s | 3.1 s |
| 3 | 200 | 11.0 | 0% | 10.9 s | 13.7 s |
| 4 | 400 | 13.9 | 1.6% | 15.9 s | 18.8 s |
| 5 | 800 | 16.3 | 74% | 27.6 s | timeout |

Throughput tops out near 20 requests per second around 100 users and never grows again. After that, every additional user only lengthens the queue: the p50 climbs from 2 seconds at 100 users to 11 at 200 and 16 at 400, then the timeouts start. Below about 15 users the app is quick, with a 10 ms median and a 500 ms p95 that is purely the login step.
The cause is arithmetic. The run attempted 1,128 logins in five minutes, 3.8 per second, and a bcrypt cost-13 verify takes 479 ms on this CPU, so two cores can do at most about four of them per second. The machine spent nearly all of its CPU hashing passwords; the saturation point was the login step, not Doctrine, not sessions, not Symfony.
How many concurrent users can a Symfony app handle?
There is no general number. This authentication-heavy workload, where every iteration logs in, reached its knee at about 100 virtual users on this 2-vCPU machine at bcrypt cost 13, and at about 400 once the cost dropped to 10, as the tuning section shows. The limit in this Symfony load test was CPU spent on password hashing, not the framework, and a workload that logs in once and then browses would land somewhere else entirely. That is why you measure your own app instead of quoting someone else's number.
FrankenPHP Worker Mode vs PHP-FPM: Same App, Same Test
The comparison everyone quotes is 1,240 requests per second on nginx and php-fpm against 3,850 on FrankenPHP for a JSON endpoint on a t3.medium. Those numbers are real, and they measure bootstrap cost, because bootstrap is nearly all a hello-world endpoint does.
To see what worker mode does for an app with a database, sessions and password hashing, the same shop was rebuilt on nginx and php-fpm with the same PHP version, opcache and preload settings, and a static php-fpm pool of pm.max_children = 5, sized to FrankenPHP's total PHP thread count on this 2-vCPU machine (four worker threads plus one non-worker thread). FrankenPHP's worker count was left at its default of two per CPU, so php-fpm actually had five request-serving processes against FrankenPHP's four workers. If that skews anything, it skews in php-fpm's favour. The image was deployed to the same Fly app with the same PostgreSQL and Redis, and the identical test ran again.
| Same staged ramp | php-fpm + nginx | FrankenPHP worker | Change |
|---|---|---|---|
| Requests served in 5 minutes | 3,942 | 4,477 | +14% |
| Peak requests/s | 40 | 61 | 1.5x |
| Requests/s at 50 users | 12.5 | 13.3 | +6% |
| Requests/s at 100 users (the knee) | 13.6 | 19.5 | +43% |
| p50 at 50 users | 588 ms | 433 ms | 1.4x faster |
| Product page p50 under load | 10.7 s | 4.0 s | 2.7x faster |
| Cart page p50 under load | 6.1 s | 3.1 s | 2x faster |
| Login p50 under load | 14.5 s | 12.7 s | 1.1x faster |
| Errors at 400 users | 8.6% | 1.6% | |
| 800 users | collapse | collapse | same wall |

Before the cloud runs, the same comparison on a laptop (Docker Desktop, 4 cores, 100 virtual users, 45 seconds, the three everyday scenarios) already shows the shape of the answer:
| Local preview, 100 VUs | php-fpm + nginx | FrankenPHP worker | Change |
|---|---|---|---|
| Single product read, idle server | 12 ms | 2 ms | 6x faster |
| Requests per second | 44.2 | 48.5 | +10% |
| Median response | 507 ms | 312 ms | 1.6x faster |
| p95 | 1.65 s | 1.60 s | about the same |
A sixfold gain on the read that is nothing but bootstrap, and ten percent on the mix, because the mix spends its time in bcrypt and PostgreSQL where worker mode has nothing to remove.
The Symfony load testing results in the cloud matched the laptop pattern exactly. The pages that are mostly bootstrap, the product and cart pages, ran two to three times faster under worker mode even while queueing. The login step, which is 479 ms of bcrypt plus a few milliseconds of framework, barely moved. Overall FrankenPHP served 14% more requests and reached 1.5 times the peak, and then both runtimes collapsed at 800 users for the same reason. Worker mode removes the repeated bootstrap; it cannot remove password hashing.
Is FrankenPHP faster than php-fpm for Symfony?
Yes, and by how much depends on the page. In this Symfony load testing comparison worker mode served 14% more requests overall, up to 2.7x more on the bootstrap-heavy product and cart pages, and almost nothing extra on login, where bcrypt dominates. Avoiding the repeated framework bootstrap appears to account for much of the improvement on the lightweight endpoints, and a persistent process changes other things too, such as connection reuse and warm caches, that this test did not isolate. Measure your own endpoints before promising anyone a multiplier.
The Flash Sale: Proving Checkout Never Oversells
A Symfony load test is not only about speed. This one also answers a correctness question you cannot answer with PHPUnit: when 500 units exist and thousands of buyers race for them, does the app sell exactly 500?
The checkout service reserves stock with one statement inside the order transaction:
public function reserveStock(int $productId, int $quantity): bool
{
$affected = $this->getEntityManager()->getConnection()->executeStatement(
'UPDATE product SET stock = stock - :qty WHERE id = :id AND stock >= :qty',
['qty' => $quantity, 'id' => $productId]
);
return $affected === 1;
}Zero affected rows means sold out, the service throws, the transaction rolls back, and the API answers 409. There is no explicit lock in that code, and it still cannot oversell, for two reasons.
PostgreSQL locks the row for you. Every UPDATE holds a lock on the rows it modifies until the transaction ends. Run two checkouts by hand, leaving the first transaction open, and the second shows up in pg_stat_activity as active | Lock | transactionid: it is waiting for the first transaction to finish.
The condition is re-checked after the wait. Under READ COMMITTED, a blocked update does not use the row it saw before waiting. When the first transaction commits with stock at 0, the second re-evaluates stock >= 1 against the new row, matches nothing, and returns zero rows. Check and decrement are one atomic statement, so there is no window between reading the stock and writing it.

Remove the AND stock >= :qty and the second buyer still waits on the lock, then writes -1. Locks serialise writes; they do not validate them. A CHECK (stock >= 0) constraint on the table catches that case as a hard error, which the app also carries as a backstop, but the conditional update stays the primary guard because it produces a clean 409 instead of a 500.
The run itself, 100 virtual users on the flash-sale scenario alone for 90 seconds:
-------------------------------- -------
metric value
-------------------------------- -------
product id 1001
initial stock 500
stock now 0
units sold since last reset 500
orders containing it (all time) 689
units sold (all time) 689
units still sitting in carts 74
-------------------------------- -------
[OK] Sold out, never oversold.The all-time rows span earlier runs; the row that matters is the 500 sold since the reset against a stock that ended at exactly 0.
That was the local run. The cloud version of this Symfony load test put 500 virtual users on the flash-sale scenario alone for two minutes, against the Fly box with cost-10 hashes. They made 6,431 requests at 54 per second, peaking at 116, with 6 failures in total. Checkout was called 1,969 times, wrote exactly 500 orders, and answered 409 to the rest. Stock finished at 0. And 1,715 units were still sitting in the losers' carts, which is what a real flash sale looks like the morning after.

The cost of correctness is visible in the latency: a p50 of 6.1 seconds and a p95 of 17.8 seconds. With five PHP workers, the queue formed in FrankenPHP before it ever reached the row lock; the database sampler never caught a transaction waiting, because at most five could be inside the database at once. The row lock is what made the result correct. The worker pool is what made it slow. That split is why flash sales need a different design than the everyday checkout path, reserving stock from a counter in Redis or queueing the orders, and the load test is what makes the queue visible before your customers do.
One more trap the test exposed: carts with several products lock several rows. Two carts holding the same products in opposite order can deadlock, and PostgreSQL will abort one of them after a second. The fix is to lock in a fixed order, which is one usort by product id before the loop. It is in the app now.
Tuning What the Numbers Point At
Every Symfony load testing result above points at a specific knob. Change one at a time and rerun.
Password hash cost. Symfony's auto hasher chose bcrypt cost 13. On the Fly box a single verify costs 479 ms at cost 13 and 60 ms at cost 10, so dropping the cost lifts the login ceiling roughly eightfold. Whether that is acceptable is a security decision, not a performance one. OWASP's password storage guidance puts the bcrypt floor at cost 10 and prefers Argon2id outright, which Symfony supports through the sodium hasher; that would trade CPU time for memory, and it is a different experiment. The rerun with cost 10, same box, same staged ramp, everything else equal:
| Users | Cost 13 | Cost 10 |
|---|---|---|
| 50 | 13 req/s, p50 433 ms | 18 req/s, p50 12 ms |
| 100 | 20 req/s, p50 2.2 s | 53 req/s, p50 15 ms |
| 200 | 11 req/s, p50 10.9 s | 102 req/s, p50 109 ms |
| 400 | 14 req/s, 1.6% errors, p50 15.9 s | 118 req/s, 0% errors, p50 1.1 s |
| 800 | 74% timeouts | 77 req/s, 0% errors, p50 5.2 s |

Five times the requests over the run, 22,195 against 4,477, zero errors, and the knee moved from about 100 users to about 400. Logins went from 3.8 to 15.4 per second. That is what one setting is worth, once the stored hashes match it and the upgrader is in place, when a load test has shown you where the CPU goes.
There is a trap on the way there, and the load test is what exposed it. I re-hashed all 30,000 users at cost 10 and measured a login from inside the machine: 547 ms, barely better than before. The hash itself now took 60 ms and a product read took 3.5 ms, so 480 ms were unaccounted for.
The missing time was Symfony's password migration. The hasher was still configured for cost 13, so on every login PasswordMigratingListener saw a hash that "needs rehash" and computed a fresh cost-13 hash to upgrade it. It then handed that hash to a user repository that did not implement PasswordUpgraderInterface, which silently dropped it. Every login paid for both costs and stored neither.
Two lines fix it: put the cost in configuration so it matches what is stored, and implement PasswordUpgraderInterface on the repository so migrations actually land.
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
if (!$user instanceof User) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class));
}
$user->setPassword($newHashedPassword);
$this->getEntityManager()->flush();
}
}security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: '%env(int:PASSWORD_HASH_COST)%'Log in once per session. A test that logs in on every iteration is a login benchmark. Real users log in once and browse for minutes. Shift the scenario to one login followed by ten browsing iterations and the login share of CPU drops accordingly. Model the traffic you have, not the flow that is easiest to script.
Sessions in Redis with a TTL. Already done in the test app, and the reason the HTML scenario scaled without a lock convoy. Set a TTL so 30,000 test users do not leave 30,000 keys behind. Remember that Symfony's RedisSessionHandler does not lock; if concurrent requests write to the same session, switch to the phpredis native handler with locking enabled and re-measure.
Doctrine result cache for the catalog. Product pages are read-heavy and change rarely. A result cache on the catalog query turns a 2 ms page into a fraction of that, which matters more under worker mode where bootstrap is already gone.
Worker count. FrankenPHP defaults to two worker threads per CPU (plus one thread for non-worker requests) and this test left that default alone; set num in the worker block to change it. On a login-bound workload more workers only deepen the CPU queue; on a database-bound workload more workers hide latency. The Fly CPU graph from run 1 is the evidence for which side you are on.
Automating Symfony Load Tests in CI
The core SDK returns a result object, so a Symfony load test can be a PHPUnit test with thresholds, and a failed threshold fails the pipeline.
use PHPUnit\Framework\TestCase;
use VoltTest\VoltTest;
final class ShopLoadTest extends TestCase
{
public function testShopStaysUnderBudget(): void
{
// The script returns the configured test when it is included instead of run.
/** @var VoltTest $test */
$test = require __DIR__.'/../../loadtest/symfony-shop-test.php';
$result = $test->setVirtualUsers(50)->setDuration('1m')->run();
self::assertGreaterThanOrEqual(99.5, $result->getSuccessRate());
self::assertLessThan(500, self::toMilliseconds($result->getP95ResponseTime()));
}
/** The SDK reports durations as strings such as "380.159ms" or "1.599487s". */
private static function toMilliseconds(?string $duration): float
{
return match (true) {
$duration === null => INF,
str_ends_with($duration, 'ms') => (float) $duration,
str_ends_with($duration, 'µs') => (float) $duration / 1000,
str_ends_with($duration, 's') => (float) $duration * 1000,
default => (float) $duration,
};
}
}The script's own run block is guarded so require hands back the configured test without starting it:
if (!isset($argv[0]) || realpath($argv[0]) !== __FILE__) {
return $test; // included from PHPUnit or another script
}Run it against a compose stack in GitHub Actions, gate merges on it, and rerun the full cloud test before releases. The Laravel PHPUnit integration shows the same pattern with the Laravel package's assertVT* helpers; for Symfony you assert on the result object directly.
Try VoltTest Cloud
Run your first cloud load test in minutes — 500 VUs, 10-minute runs, no credit card.
What to Do Next
- Put your app in production mode locally:
APP_ENV=prod, opcache preload, Redis sessions. A load test of a dev container measures the profiler. - Write the HTML scenario first. If CSRF, sessions and logout work under load, the API scenario is easy.
- Give each scenario its own accounts, then run 20 users for 30 seconds until the success rate is 100%.
- Only then scale up, and change one variable per run. That is the whole method behind every Symfony load testing number in this guide.
Resources
- VoltTest documentation, steps and extractors, scenarios and data sources, HTML form examples, JSON API examples, cloud mode
- The Symfony shop and load test used in this guide, including the raw result files of every run
- Symfony docs: CSRF protection, session configuration, password hashing, FrankenPHP with Symfony
- PostgreSQL docs: row-level locks and READ COMMITTED
- Related guides: How to load test Laravel applications, PHP load testing, performance testing types
VoltTest's PHP SDK is open source on GitHub. If this guide saved you a bad launch, a star helps other Symfony developers find it.