Skip to content
Muhammet Şafak
tr
Service & load

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.

Medium confidence Repeated runs, limited control of the environment. Differences of the same order are meaningful.
Measured on

measured 2 days ago

Published

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

Laravel Symfony CodeIgniter Yii2 Phalcon Laminas Slim PHP Docker nginx MySQL

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
Slim ships neither a data layer nor a template engine, so in those two scenarios it is doing less framework work than the others. Read that row alongside its results.

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
Best of 9 repetitions, 64 connections, 15 s — bench/run.sh output
Series SlimYii2PhalconSymfonyLaminasCodeIgniterLaravel
Empty route 25,97519,83415,02113,0678,3496,4315,966
Template (100 rows) 22,78016,99510,5639,3557,4195,5575,290
Database (single row) 14,4439,3665,2226,9725,4214,5273,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
Requests per second, best of 9 repetitions. Ordered by empty-route throughput.

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
Taken from the repetition that produced the best throughput, 64 connections
Series SlimYii2PhalconSymfonyLaminasCodeIgniterLaravel
p50 0.25 ms0.35 ms0.46 ms0.58 ms0.88 ms1.16 ms0.75 ms
p95 0.43 ms0.59 ms0.73 ms0.89 ms1.52 ms1.84 ms1.97 ms
p99 0.53 ms0.73 ms0.94 ms0.96 ms2.23 ms1.98 ms2.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
The same JSON response, the same container, the same run. The only difference is the route sitting behind the web group. p99 goes up twenty-five fold.

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
Eight php-fpm workers on four cores. The ceiling is already reached at eight connections; beyond that the requests queue in nginx. This table bounds the ceiling, it does not draw a saturation curve.

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

  1. There is exactly one PHP version all seven agree on: 8.4. Symfony 8.1 requires at least 8.4.1; laminas/laminas-mvc 3.8 accepts no more than ~8.4.0. The window is one minor version wide.
  2. 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.
  3. CodeIgniter 4 has no PDO driver — its MySQL support is MySQLi only. ext-mysqli is installed for all seven so the extension set stays identical; the other six never use it.
  4. Laminas’s official skeleton has not been released for PHP 8.4. laminas/laminas-mvc-skeleton 2.4.0 still declares ~8.1.0 || ~8.2.0 || ~8.3.0 and refuses to install without --ignore-platform-req=php. The component it boots, laminas/laminas-mvc 3.8.0, does accept ~8.4.0.
  5. 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.
  6. 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.
  7. Slim does less work in two scenarios. It has no data layer (/db runs 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.
  8. One cell contradicted itself and was topped up. Slim’s /route came 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 of 8/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 /route settled at 24,774 and no cell remained flagged.
  9. Zero non-200 responses. After the persistent-connection fix, all 546 load measurements returned HTTP 200 for every request.
  10. 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.
  11. 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

Share:

Other records

All records
Tooling comparison Measurement

The fixed cost of installing a PHP framework: disk size tells you nothing

How many megabytes on disk, how many files per request and how many milliseconds on the first request do seven PHP frameworks cost — and which of those numbers actually predicts throughput under load?

Finding

Disk size predicts nothing: Yii2 has the largest vendor tree at 34.1 MB and loads only 62 files per request, among the fewest in the field. Files per request predicts nothing either: CodeIgniter loads 96 files and serves 6,431 req/s, Symfony loads 224 and serves 13,067. The one number that genuinely separates them is the first request served with a cold opcache: 2.2 ms for Phalcon, 72.2 ms for Laravel — thirty-three times. That is the compile bill the first visitor pays after every deploy, and it is measured in tens of milliseconds, not kilobytes.

High confidence
Frontend performance Measurement

How you import Chart.js decides how many kilobytes the visitor downloads

In a real production build, what is the difference between `chart.js/auto` and a selective `Chart.register()` — in kilobytes?

Finding

Selective registration saves 9.7 kB gzip over `chart.js/auto` (67.8 → 58.1 kB, 14.3%). The larger drop is not in the library core but in leaving unused controllers out: a page that registers only the bar chart falls to 46.0 kB — two thirds of auto.

High confidence

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind