Seven PHP frameworks under identical load: the gap narrows as soon as the request does real work
On the same hardware, the same PHP build and the same seven routes, how many requests a second do Laravel, Symfony, CodeIgniter, Yii2, Phalcon, Laminas and Slim serve, and at what latency?
Finding
On an empty route the fastest is 4.4× the slowest (Slim 25,975, Laravel 5,966 req/s). As soon as the request does real work the gap closes: 3.7× for a single row from the database, 3.5× for twenty rows. Phalcon is third on an empty route and fifth once a query is involved — being a C extension buys nothing while the process waits on MySQL. And the expensive decision is not the framework: Laravel's own default `web` middleware group takes the same response from 5,858 to 2,176 req/s, so one default costs more than most of the distance between the frameworks.
- Empty route, fastest
- 25,975 req/s
- Empty route, slowest
- 5,966 req/s
- Gap: empty route → database
- 4.4× → 3.7×
- Laravel's web group
- −63%
Method
All seven candidates ran on a single PHP 8.4.24 build, on byte-identical opcache/php.ini/php-fpm pool settings (pm=static, 8 workers) and one nginx server block. Each was installed from its official skeleton with its own documented production set-up (route/config/view caches, APP_ENV=prod, YII_DEBUG=false, Volt compiled ahead of time) and the same composer flags. All seven register exactly fifty routes with the parameterised one always at position 47. Only one candidate runs at a time; php-fpm sits on cores 0-3, nginx on 4-5, MySQL on 6-7 and the load generator on 8-11, disjoint. Every scenario runs for 15 seconds at 64 connections, nine repetitions, with the candidate order reshuffled each round. A fixed nginx reference that never reaches PHP is sampled before and after every block, recording the host capacity that measurement was given. The reported value is the best of the repetitions: the machine could not be quiesced, and interference can only ever subtract throughput, never push it past what the hardware allows. 546 load measurements, 150 host probes, zero non-200 responses.
- Measured on
- Published
measured 2 days ago
Environment
- PHP
- 8.4.24 NTS · opcache on · JIT off
- Server
- nginx 1.29.1 + php-fpm · pm=static · 8 workers
- Database
- MySQL 8.4.11 · 10,000 rows · persistent connections
- Load generator
- oha 1.15.0 · 64 connections · 15 s
- Hardware
- Apple M4 Pro · 12 cores · 24 GB · macOS 26.6.1
- Virtualisation
- Docker Desktop 29.7.2 · 12 vCPU / 7.75 GB · aarch64
- Core split
- fpm 0-3 · nginx 4-5 · MySQL 6-7 · load 8-11
- Repetitions
- 9 (+6 top-up) · best run reported
Technologies
To reproduce
./bench/build.sh && STAMP=$(date -u +%F) ./bench/run.sh --all Most PHP framework comparisons publish a number without publishing how it was produced: which version, which hardware, how many repetitions. What this record contributes is not the ranking but the fact that the ranking can be re-derived. Every figure below comes out of bench/run.sh and every raw run is committed in the repository.
What was measured
Each candidate ran at its latest stable release, from its official skeleton, in its own documented production set-up. Exactly one thing varies: the framework.
| Candidate | Version | Data layer | Templates |
|---|---|---|---|
| Slim | 4.15.2 | none — raw PDO | none — slim/php-view |
| Yii2 | 2.0.55 | ActiveRecord | Yii View |
| Phalcon | 5.19.0 | Phalcon Model | Volt |
| Symfony | 8.1.4 | Doctrine ORM | Twig |
| Laminas | 3.8.0 | laminas-db TableGateway | laminas-view |
| CodeIgniter | 4.7.4 | CI Model | CI View |
| Laravel | 13.26.1 | Eloquent | Blade |
All seven register exactly fifty routes, with the parameterised route always at position 47, so route resolution is measured in a realistic table rather than a one-entry one. The scenarios: plain text, JSON, a parameterised route, a five-layer middleware chain, a hundred-row template (no database), a single row by primary key, and a twenty-row list.
What the numbers say
Requests served per second, across three scenarios
The bars converge as the request goes from an empty route to a database query: the framework's own overhead stops being the dominant cost once real work is involved.
- Empty route
- Template (100 rows)
- Database (single row)
Source: Best of 9 repetitions, 64 connections, 15 s — bench/run.sh output
Data table
| Series | Slim | Yii2 | Phalcon | Symfony | Laminas | CodeIgniter | Laravel |
|---|---|---|---|---|---|---|---|
| Empty route | 25,975 | 19,834 | 15,021 | 13,067 | 8,349 | 6,431 | 5,966 |
| Template (100 rows) | 22,780 | 16,995 | 10,563 | 9,355 | 7,419 | 5,557 | 5,290 |
| Database (single row) | 14,443 | 9,366 | 5,222 | 6,972 | 5,421 | 4,527 | 3,887 |
The chart is drawn in the browser; the table below carries the same data.
The most striking movement is Phalcon’s. Third on the empty route (15,021), third again on the template, and fifth once a database query is involved (5,222) — below Symfony. On the twenty-row list it slips further, to sixth (2,040), behind Laminas and CodeIgniter. A framework written in C gains nothing while the process is waiting on MySQL; what it gains is where PHP would be running PHP.
| Candidate | Empty route | JSON | Route (47/50) | Middleware | Template | DB single row | DB 20 rows |
|---|---|---|---|---|---|---|---|
| Slim | 25,975 | 25,609 | 24,774 | 24,020 | 22,780 | 14,443 | 4,551 |
| Yii2 | 19,834 | 18,731 | 18,775 | 18,877 | 16,995 | 9,366 | 3,245 |
| Phalcon | 15,021 | 15,161 | 15,061 | 14,906 | 10,563 | 5,222 | 2,040 |
| Symfony | 13,067 | 12,714 | 12,187 | 12,964 | 9,355 | 6,972 | 3,060 |
| Laminas | 8,349 | 8,114 | 8,127 | 8,036 | 7,419 | 5,421 | 2,874 |
| CodeIgniter | 6,431 | 6,233 | 5,960 | 6,037 | 5,557 | 4,527 | 2,494 |
| Laravel | 5,966 | 5,848 | 5,603 | 5,704 | 5,290 | 3,887 | 1,293 |
The closing of the gap fits in one line: the ratio of fastest to slowest is 4.4× on an empty route, 3.7× on a single-row database request and 3.5× on a twenty-row one. Phalcon’s lead over Laravel falls from 2.5× to 1.3×.
Latency
Latency percentiles on the JSON scenario
All seven are sub-millisecond at p99: at this load none of them feels slow to a user. The difference is how many users the same hardware can carry.
- p50
- p95
- p99
ms lower is better Source: Taken from the repetition that produced the best throughput, 64 connections
Data table
| Series | Slim | Yii2 | Phalcon | Symfony | Laminas | CodeIgniter | Laravel |
|---|---|---|---|---|---|---|---|
| p50 | 0.25 ms | 0.35 ms | 0.46 ms | 0.58 ms | 0.88 ms | 1.16 ms | 0.75 ms |
| p95 | 0.43 ms | 0.59 ms | 0.73 ms | 0.89 ms | 1.52 ms | 1.84 ms | 1.97 ms |
| p99 | 0.53 ms | 0.73 ms | 0.94 ms | 0.96 ms | 2.23 ms | 1.98 ms | 2.59 ms |
The chart is drawn in the browser; the table below carries the same data.
Laravel’s p50 (0.75 ms) is better than Laminas’s and CodeIgniter’s, while its p99 (2.59 ms) is the worst in the field. Laravel finishes the typical request quickly and handles the tail badly.
The arguments are cheap, the defaults are not
Resolving a parameterised route in a fifty-route table costs between 0% and 7% against an empty route. A five-layer middleware chain costs 1% to 8%. The two things developers argue about most when comparing frameworks turn out to be single-digit percentages.
Then you look at what Laravel ships by default:
| Laravel route | req/s | p50 | p99 |
|---|---|---|---|
| No middleware group | 5,858 | 1.32 ms | 2.88 ms |
| Default web group session + cookie encryption + CSRF | 2,176 | 1.73 ms | 71.89 ms |
This was measured with the file session driver. Laravel’s shipped default is
database, which adds a read and a write per request on top. The real figure is
worse than the one above.
Saturation: the ceiling is eight workers
Raising the connection count from 8 to 256 left throughput and p99 flat for all seven.
| Connections | Slim | Yii2 | Phalcon | Symfony | Laminas | CodeIgniter | Laravel |
|---|---|---|---|---|---|---|---|
| 8 | 25,812 | 19,242 | 14,894 | 12,817 | 8,149 | 6,193 | 5,909 |
| 32 | 25,785 | 19,018 | 15,053 | 12,811 | 7,828 | 6,259 | 4,774 |
| 64 | 26,234 | 18,964 | 14,802 | 9,324 | 8,114 | 6,276 | 5,890 |
| 128 | 25,842 | 19,251 | 14,783 | 11,795 | 8,044 | 6,067 | 6,016 |
| 256 | 25,802 | 19,808 | 15,109 | 12,129 | 8,075 | 6,140 | 5,901 |
How it was measured — and why it was restarted twice
This measurement was run three times. The first two were abandoned, both found a real defect, and both are kept in the repository with the reasoning.
First abort — the machine was not quiet. Slim’s /json gave 15,260 req/s in
one repetition and 2,107 in the next, with p50 at 0.23 ms in both. Identical
per-request cost, seven times fewer requests: the application had not slowed
down, the run had not been given CPU. The host is a laptop running its window
server, a terminal and an editor; cpusets isolate a container inside the VM,
they cannot isolate the VM from macOS.
The fix for that is not to take the average. Interference can only ever subtract throughput — a run can be slowed by a noisy neighbour, but it cannot be faster than the hardware allows. So the reported figure is the best of the repetitions, with the median and the spread published beside it. A fixed nginx reference that never reaches PHP is also sampled before and after every block: across 150 samples the worst was 84% of the best.
Second abort — port exhaustion. Half the requests in the database scenarios
were returning 500: SQLSTATE[HY000] [2002] Cannot assign requested address.
php-fpm opens a fresh TCP connection to MySQL on every request and the container
runs out of ephemeral ports at a few thousand requests a second. The single
pre-flight request could never catch that. All seven moved to persistent
connections (PDO::ATTR_PERSISTENT, pConnect for CodeIgniter) — the documented
production setting for php-fpm. The effect is large: Slim’s /db went from 3,594
to 14,074 req/s, which means the earlier measurement was measuring port churn
rather than the data layer.
Findings that did not fit the table
- There is exactly one PHP version all seven agree on: 8.4. Symfony 8.1
requires at least
8.4.1;laminas/laminas-mvc3.8 accepts no more than~8.4.0. The window is one minor version wide. - Phalcon and Symfony cannot share a PHP process. Phalcon requires
ext-psr, and with that extension loaded Symfony is fatal on PHP 8.4:Declaration of Symfony\Component\Cache\CacheItem::expiresAt(?DateTimeInterface $expiration): static must be compatible with PsrExt\Cache\CacheItemInterface::expiresAt($expiration). The C extension declares the PSR interfaces without the parameter types the userland packages use. The “one image, one extension set” plan therefore forks in exactly one place: Phalcon runs on its own runtime image, the other six share one. - CodeIgniter 4 has no PDO driver — its MySQL support is MySQLi only.
ext-mysqliis installed for all seven so the extension set stays identical; the other six never use it. - Laminas’s official skeleton has not been released for PHP 8.4.
laminas/laminas-mvc-skeleton2.4.0 still declares~8.1.0 || ~8.2.0 || ~8.3.0and refuses to install without--ignore-platform-req=php. The component it boots,laminas/laminas-mvc3.8.0, does accept~8.4.0. - Yii2 is still 2.0.x. Yii3 has no stable release, so the latest stable Yii is a line whose architecture predates the others’ current versions.
- Middleware is not the same concept in all seven. CodeIgniter filters and Yii2 action filters are scoped to a route and cost the other scenarios nothing. Symfony, Laminas and Phalcon have no per-route pipeline; the documented equivalent is a global event listener, so in those three all five layers are consulted on every request and return early. That is their architecture, not a handicap invented here.
- Slim does less work in two scenarios. It has no data layer (
/dbruns on raw PDO) and no template engine of its own (slim/php-view). Its first place in those two rows should be read with that in mind. - One cell contradicted itself and was topped up. Slim’s
/routecame out at 6,778 req/s over nine repetitions — with the lowest p50 in the field (0.23 ms), at 26% of its own empty-route throughput. None of the nine had landed in an unclipped window. Two rules fixed in advance (two or fewer clean repetitions, or throughput below half of8/p50) flagged six cells, all of them Slim or Yii2 — the fastest two, which is the direction the bias was predicted to run. Six repetitions were added to each; Slim’s/routesettled at 24,774 and no cell remained flagged. - Zero non-200 responses. After the persistent-connection fix, all 546 load measurements returned HTTP 200 for every request.
- JIT was off. None of the seven documents opcache JIT as a production step, and enabling it would have added a second variable. Results with JIT on may differ; that is a separate measurement.
- Worker mode was not measured. Persistent-process models such as FrankenPHP or Octane are not officially supported by all seven; classic php-fpm is the one floor they share. Worker mode deserves its own record.
Related posts
Building Applications with PHP in 2026: State of the Ecosystem
The real state of PHP in 2026: language maturity, ecosystem health, and why the 'it's dead' narrative is still wrong.
Twelve years with Laravel: growing alongside a framework
From 2014 to 2026, twelve years with Laravel — a story of maturing with a tool, outgrowing it, and choosing it again.
Is PHP Dead? The 2026 Picture, by the Numbers
A view from 18 years of PHP development — the 2026 state of PHP through W3Techs, Stack Overflow, JetBrains, and Packagist data, including the language's often-ignored weaknesses.