Posts

Performance Benchmark Report second half of 2025 for Shopware 6

How does your Shopware 6 store’s PHP backend performance compare to other operators of Shopware in general?

To answer this question, we have aggregated and anonymized performance data from over 200 Shopware 6 stores over the second half of 2025 and computed benchmark numbers to compare to for the most important page types: Product details, Category, Search, and Homepage.

We previously made these benchmarks for 2025 Q1, and 2025 Q2. Going forward, these will be published every 6 months.

Executive Summary

  • For the Shopware 6 operators in the Top 10% and 25%, the numbers show what is possible if you operate with a focus on backend performance. The top 10% of shops perform in the range of 200-300 ms on average and 400-600 ms in the 95th percentile.
  • Comparing with the previous Q2 benchmark, we see the numbers for product and category pages improving slightly across all categories on average, and decreasing for the 95th percentile. For search, performance degrades by 20%–40% in all cohorts.
  • The slowest 25% of shops have worse performance than in Q2 2025, showing a negative trend. This trend persists even when accounting for the high traffic Christmas season.
  • The fastest and slowest 10% are 6–26 times apart, showing that there is tremendous potential in analyzing and optimizing PHP backend performance with tools like Tideways.
  • Clearly, Shopware shops are not taking advantage yet of the performance improvements made to HTTP caching and other subsystems in the 6.6 and 6.7 versions.

Benchmark of average response times

Page10% Best25% BestMedian25% Worst10% Worst
Product Details271 ms415 ms639 ms927 ms1535 ms
Category Page247 ms463 ms735 ms1215 ms1919 ms
Search335 ms495 ms927 ms1663 ms3967 ms
Homepage57 ms135 ms335 ms671 ms1279 ms

Benchmark of 95th percentile response times

Page10% Best25% BestMedian25% Worst10% Worst
Product Details447 ms703 ms1151 ms1727 ms3071 ms
Category Page575 ms991 ms1599 ms2559 ms4607 ms
Search575 ms863 ms1663 ms3199 ms7679 ms
Homepage95 ms335 ms735 ms1471 ms2943 ms

Methodology

The data foundation for this is daily average and 95th response times of PHP requests in Shopware 6 shops that have an active Tideways subscription or trial during the second half of 2025 and reported data to our backend. This is measured through the Tideways PHP extension, tapping into the PHP runtime.

From a Core Web Vitals perspective, this is an approximate benchmark for the Time to First Byte (TTFB) of all requests in the shop that are served by the PHP backend. Requests served cached from Varnish, Fastly or other HTTP cache in front of the shop are not included.

A shop must have had at least 5,000 storefront requests that day processed by PHP in the backend and 250 requests of the page type to be included. Only shops using the default controllers for the product details, category list, search, cart, and homepage page types are considered. This leads to 151,193 data points included in the report, over 74,607 more compared to the last report.

Take these numbers with a grain of salt because they intentionally include Shopware users across all major PHP and Shopware versions, with Elasticsearch or MySQL-based search, with Shopware’s HTTP Cache enabled or using external HTTP caches (Varnish) and across all industries and customer personas.

See where your store ranks

To see where your Shopware 6 store is ranking, sign up for a free trial with Tideways and collect backend performance data. Tideways can recommend performance optimizations and provides effortless insights into all code-level performance problems.

PHP Benchmarks: 8.5 vs 8.4, 8.3 and 7.4

Each year, right on schedule, a new version of PHP is released at the end of November.

So, how much faster is this new release across popular frameworks and applications?

Our tests show that, in general, the performance between 8.2, 8.3, 8.4 and 8.5 does not move much for a Laravel, Symfony and WordPress demo application.

Moving to the newest PHP version isn’t a magic shortcut to better performance.

Not everything is bleak, though. In our post on PHP 8.5 Performance, Operations, and Debugging, we highlight several concrete ways to leverage new features to boost performance.

Keep in mind, however, that these improvements are only relevant if you use the corresponding functionality and may require some effort to integrate.

The most significant factor for performance is your application architecture and the code you write. To identify where to begin optimizing your application, Tideways—our production-ready profiling and monitoring tool—can help pinpoint areas for improvement.

To benchmark PHP, we have set up these popular PHP projects:

  • Symfony with PHP 8.5, 8.4, 8.3, and 8.2
  • WordPress with PHP 8.5, 8.4, 8.3, and 7.4
  • Laravel with PHP 8.5, 8.4, 8.3, 8.2

Our intention is to give you a rough idea of how much performance is improved as a percentage by just updating the PHP version.

WordPress supports many PHP versions in parallel, so we use it as a good yardstick for performance changes from PHP 7 to 8.5.

Setup

The benchmarking is done under these conditions:

  • Code and Infrastructure Provisioning: github.com/tideways/php-benchmarks
  • Machine: Hetzner CCX 33 (8 dedicated vCores on AMD)
  • OS: Debian 13 („Trixie“)
  • Database:
    • MySQL 8.4.7 for WordPress and Laravel
    • SQLite 3 for Symfony
  • PHP (all built by deb.sury.org): 7.4.33, 8.2.29, 8.3.27, 8.4.14, 8.5.0 RC 3
    • JIT not enabled
    • FPM with static pool and 17 workers.
  • Projects: Laravel 12.37.0, Symfony 7.3.6, WordPress 6.8.3
  • Vegeta v12.12.0
  • HAProxy 3.0.11

You can find more on methodology after the results.

Results

Symfony

Upgrading just PHP 8.4 to 8.5 makes the Symfony demo application run with almost the same performance when simulating a fixed number of 100 requests/minute. Fluctuations are within the margin of error.

When run with 15 concurrent requests, the requests/seconds also do not vary significantly between PHP versions:

Laravel

Upgrading just PHP 8.4 to 8.5 shows no visible difference to response times of the Laravel demo application.

And the requests/seconds are close to each other as well.

WordPress

Similar with WordPress, upgrading from PHP 8.4 to 8.5 shows no significant change in response times.

And the requests/seconds for 15 concurrent users are close to each other, with only PHP 7.4 showing a ~5% lower number.

❗With Tideways PHP Profiler, you can find bottlenecks in your application code by continuously profiling the production environment at low overhead. Get your free 14 -day trial to start optimizing.

More Notes on Methodology

With benchmarking, the results heavily rely on the assumptions and setup. There are a few changes we have made to other popular PHP benchmarks, and we want to discuss our methodology here.

We run the benchmarks in two modes:

  • With fixed requests per second, with a focus on response times
  • With a fixed level of concurrency, with a focus on requests per second.

Why do we not only report performance in requests per second? Other benchmarks often compare if a new PHP version can serve more requests per second. They do this by running as many requests as possible with a fixed number of concurrent threads that create new requests.

This provides synthetic or artificial comparisons because for real-world scenarios you would never run your PHP application at the limit of capacity and getting as many requests out as possible.

Instead, response time or time-to-first-byte (TTFB) under regular load is what you are interested in to understand how performance affects real users.

We don’t run the tests with very high concurrency because we want to make sure the numbers reflect PHP performance, not the operating system’s process scheduler, thus no CPU contention is happening.

What’s new in PHP 8.5 in terms of performance, debugging and operations

The close of 2025 is near, and that also means a new version of PHP is about to be released: 8.5!

There has already been some discussion regarding the latest features and modifications affecting developers, for example on Laravel News, PHP.Watch or the Zend Blog.

In this post we are highlighting just the performance, debugging, and operations-related changes in PHP 8.5 that you will not find in the posts listed above.

Several of these changes were even contributed by Tideways employees.

Are you mostly curious whether PHP 8.5 outperforms earlier versions? Take a look at our benchmark.

Performance

OPCode specialization for === []

The PHP compiler can optimize opcodes to run more efficiently, and for 8.5, my colleague Tim added an optimization for the $array === [] check. Before, this way of testing for an empty array was the slowest when compared with !$array, count($array) === 0 or empty($array).

In PHP 8.5 this becomes the fastest way of testing for an empty array. Should you change all code now? No. But it shows that when you micro-optimize code, you might lose that benefit in a next PHP version when the engine evolves and an entirely different code path is faster now, and maybe it’s best to just write what you find most readable and bet on future engine improvements.

I made a video about this change a few months ago: Stop Micro-Optimizing Your PHP Code (Unless You Rely On This).

Optimize match (true)

Another compile time optimization that Tim made for 8.5 is to reduce the number of opcodes that are emitted when you have a match (true) statement.

For an artificial code example such as the following, this provided a 17% (+/- 6%) improvement:

<?php

function foo($text) {
    $result = match (true) {
        !!preg_match('/Welcome/', $text), !!preg_match('/Hello/', $text) => 'en',
        !!preg_match('/Bienvenue/', $text), !!preg_match('/Bonjour/', $text) => 'fr',
        default => 'other',
    };

    return $result;
}

$i = 1_000_000;
$text = 'Bienvenue chez nous';
while($i--) {
	foo($text);
}

Persistent cURL Handles for DNS, Connection, and SSL Handshake Reuse

PHP’s shared nothing architecture is very helpful in avoiding memory leaks and accidental data leaks between users. But it has its downsides when you are performing the same work in each request over and over again.

For example, DNS, Connection, and SSL handshake times in cURL HTTP requests.

With PHP 8.5, you can now cut this time down significantly by sharing the DNS, connection, and SSL handshake information between HTTP calls made from different PHP requests using a new function, curl_share_init_persistent.

When you run this piece of code multiple times in a web server with PHP 8.5, you see how the second call gets much faster and by how much looking at the cURL timers:

<?php

$sh = curl_share_init_persistent([CURL_LOCK_DATA_CONNECT, CURL_LOCK_DATA_SSL_SESSION, CURL_LOCK_DATA_DNS]);
$ch = curl_init("https://tideways.com");

curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SHARE, $sh);

curl_exec($ch);

foreach (curl_getinfo($ch) as $k => $v) {
    if (str_ends_with($k, '_time')) {
        echo sprintf("%20s", $k) . "\t" . $v . PHP_EOL;
    }
}

This change was contributed by Eric Norris through the “Add persistent curl share handles” and “Persistent curl share handle improvement” RFCs.

I recorded a video on cURL share handles with a detailed example and numbers.

Improve Performance of Instantiating Exceptions and Errors

Generally, exceptions shouldn’t happen often in PHP applications, but there are cases where they are either by accident or fully on purpose. This got slightly faster with this PR by Niels Dossche. It moves some checks to debug builds of PHP and skips them in production builds.

Improved Function Performance

The following core functions got performance optimizations in PHP 8.5: array_find(), array_filter(), array_reduce(), usort() / uasort(), str_pad(), implode(), pack(), and ReflectionProperty::getValue() + variants.

Operations

OPcache is now a required Extension

With PHP 8.5, OPcache is now a required extension, automatically built into every PHP binary, thanks to this RFC by Tim, Arnaud, and Ilija. There is not an option to run PHP without it anymore or accidentally forgetting to install it, which was a common problem with Docker containers using the php Docker Official Image.

The goal here is to simplify maintenance and allow making use of the Optimizer part of OPcache in a simpler way and share/move code to the engine.

It’s still possible to disable OPcache through php.ini settings.

OPcache File Cache Read-Only Support

Before PHP 8.5, you could not use the file cache of OPcache in combination with a read-only filesystem. This made it impossible to benefit from the file cache in container setups where the deploy and run steps are separated. In these scenarios the cold startup time is something heavily optimized, for example on AWS Lambda when running bref.

This PR by Samuel added a new INI option opcache.file_cache_read_only. With this option set, OPcache does not invalidate files anymore and does not run code that would modify the filesystem.

The PR mentions it should be combined with the settings opcache.validate_timestamps=0, opcache.enable_file_override=1and opcache.file_cache_consistency_checks=0 for best results.

The PR also includes a firsthand experience from bref author Matthieu Napoli, who mentioned he saw a 100ms drop in cold startup times on AWS Lambda for a test application.

New max_memory_limit INI directive

Before PHP 8.5, you could essentially change the memory limit to unhealthy high numbers by calling ini_set("memory_limit") without guards. This can now be prevented by configuring the system INI setting max_memory_limit which defines the upper limit of how high the memory limit can be set at runtime. The change was contributed by Frederik Pytlick in this PR.

Add new PHP_BUILD_PROVIDER constant

Tim contributed another small change that can be very helpful to general application. It was already possible to compile PHP with the PHP_BUILD_PROVIDER environment flag to provide information on who was responsible for the build. This was shown in phpinfo() and php -v outputs. With a new PHP constant PHP_BUILD_PROVIDER you can now also access this information at runtime.

Various people also already worked this into upstream PHP builds of Homebrew, Debian, Docker, and Fedora.

Debugging

php --ini=diff

With a new option on the command line, you can now see a list of all INI variables that were changed from the default value. This will be very helpful for debugging and bug reporting your PHP installation.

Add runtime-enabled heap debugging capabilities

With this PR from Arnaud, PHP 8.5 gains memory debugging capabilities without having to compile it with specialized tools like ASAN/MSAN/Valgrind, which might be hard to come by in production. Our colleague Tim worked on this as well, as we have had this capability wish when debugging Tideways PHP extension on customer systems a few times.

By setting the environment variable ZEND_MM_DEBUG you can enable different memory debugging functionality.

Error Backtraces

Fatal Errors that are outputted from PHP now show the backtrace by default thanks to this RFC from Eric Norris. Previously a fatal error would only show the message.

New functions: get_exception_handler(), get_error_handler()

With this RFC from Arnaud, two new functions were added to PHP 8.5 that give access to a callable of the current exception or error handler through two new functions: get_exception_handler and get_error_handler.

Improve MySQL Database Performance with the InnoDB Buffer Pool configuration

The database is often the source of performance problems in PHP applications, but there are many different reasons why this is the case.

The most straightforward is that individual queries that the application issues are slow, due to their inefficient structure, by not using indexes or other coding mistakes.

But for MySQL databases, a common problem is also the misconfiguration of the server itself. And MySQL has a single setting that is overwhelmingly responsible for the performance, called InnoDB Buffer Pool Size.

The setting declares how much of the database tables and indexes can be cached in memory (RAM). MySQL performance experts recommend keeping all data and indexes in memory.

Because databases are usually growing over time, this setting is not a “once and forever” setting, but it should be re-evaluated from time to time.

So if you are experiencing slow database queries, before blaming the individual query, you should always keep in mind to check if the database is too big for the InnoDB Buffer Pool.

Because if the database is too big, then depending on the application usage patterns, InnoDB keeps changing what it stores in memory constantly, always purging data again from the cache, and re-loading it from disk at high expense when its needed again.

Determine current setting of InnoDB Buffer Pool

To find out what the current size of the InnoDB buffer pool is, you can issue the following queries against your MySQL database:

SELECT variable_value/1024/1024 as innodb_buffer_pool_size_mb
FROM performance_schema.global_variables
WHERE variable_name = 'innodb_buffer_pool_size';

SELECT @@innodb_buffer_pool_size / 1024 / 1024  as innodb_buffer_pool_size_mb;

+----------------------------+
| innodb_buffer_pool_size_mb |
+----------------------------+
|                          8 |
+----------------------------+

In this case its 8 MB of memory. An extremely low value! The default value according to the MySQL docs is 128MB, however different distribution packages might already change that to something different.

Determine InnoDB Buffer Pool Cache Hit Rate

There are several ways to determine the current buffer pool cache hit rate. On the internet you often find instructions run the following command, looking at a huge blog of text of unstructured data.


mysql> SHOW ENGINE INNODB STATUS\G

The output is rather large so that sometimes it makes sense to set the pager tool to less before:

mysql> pager less
PAGER set to 'less'

Search for the section “BUFFER POOL AND MEMORY” it will look like this:


BUFFER POOL AND MEMORY
----------------------
Total large memory allocated 0
Dictionary memory allocated 1852330
Buffer pool size   1048448
Buffer pool size, bytes 17177772032
Free buffers       8216
Database pages     1028928
Old database pages 379656
Modified db pages  14757
Pending reads      0
Pending writes: LRU 0, flush list 0, single page 0
Pages made young 867782, not young 1408418
0.07 youngs/s, 0.00 non-youngs/s
Pages read 885237, created 235215, written 33162859
0.00 reads/s, 0.00 creates/s, 46.88 writes/s
Buffer pool hit rate 1000 / 1000, young-making rate 0 / 1000 not 0 / 1000
Pages read ahead 0.00/s, evicted without access 0.00/s, Random read ahead 0.00/s
LRU len: 1028928, unzip_LRU len: 0
I/O sum[19968]:cur[0], unzip sum[0]:cur[0]

You find the note “Buffer pool hit rate” at the bottom and it shows a 1000 / 1000, which is the best value you can get.

There is a better way to get this information from MySQLs internal data tables:


SELECT HIT_RATE FROM information_schema.INNODB_BUFFER_POOL_STATS;

This can also be calculated through two global status variables, and you can run the following SQL query to get the same value:


SELECT 1000 * (1 - ( 
   (SELECT variable_value FROM performance_schema.global_status WHERE variable_name = 'innodb_buffer_pool_reads')
    / (SELECT variable_value PageSize FROM performance_schema.global_status WHERE variable_name = 'innodb_buffer_pool_read_requests')
)) AS innodb_cache_hit_rate;

Outputs:


+-----------------------+
| innodb_cache_hit_rate |
+-----------------------+
|     971.9755334547322 |
+-----------------------+

This gives a hit rate of 99,999%, or a 1000 / 1000 as calculated in the InnoDB buffer pool status.

This is a very good value, but when its worse than 90%, then this usually means that too many reads are not hitting the buffer pool as mentioned before.

InnoDB status seems to multiply the percentage by 1000 instead of 100 to get “human readable” values, 971.975 essentially means 97,1975%.

But what time frame are those numbers relating to? Reading the MySQL docs very carefully it seems that the following is true, though I am not 100% sure about this. Please comment if you know more.

CommandTimeframe
SHOW ENGINE INNODB STATUSSince last time command was run
SELECT HIT_RATE FROM information_schema.INNODB_BUFFER_POOL_STATS;Since last time the command was run
Selecting from global status variables innodb_buffer_pool_reads and innodb_buffer_pool_read_requestsSince server startup

If you want to calculate how high the number is in a fixed time window, you need to store the variables innodb_buffer_pool_reads and innodb_buffer_pool_read_requests regularly and compute the hit rate on the difference of consecutive measurements.

30 Minutes agoNowDifference
innodb_buffer_pool_reads857970858845875
innodb_buffer_pool_read_requests4009269570614030386749482111717887

This blog post on INNODB SHOW ENGINE STATUS walk-through by renowned MySQL performance expert Peter Zaitsev of Percona has little additional information about a good or bad value, but mentions that it depends on the application when a bad cache hit ratio starts leading to increased I/O.

How big is my MySQL dataset including indexes?

To understand how big the InnoDB Buffer pool should be, you need to look at how big the dataset actually is and there is this handy query that you can run to find that out:


SELECT
  table_schema as `database`,
  table_name as `table`,
  SUM(round(((data_length+index_length)/1024/1024),2)) as size_mb
FROM information_schema.tables
WHERE engine = "InnoDB" AND table_type = "BASE TABLE"
  AND table_schema not in ("mysql", "sys", "information_schema", "perfomance_schema")
GROUP BY `database`, `table` WITH ROLLUP;

For my Shopware Demo shop application this results in the following, with most rows cut for readability:


+------------+-------------------------------------------------+---------+
| database   | table                                           | size_mb |
+------------+-------------------------------------------------+---------+
| shopware67 | acl_role                                        |    0.02 |
| shopware67 | category                                        |   21.47 |
| shopware67 | category_tag                                    |   11.61 |
| shopware67 | category_translation                            |   20.45 |
| shopware67 | product_search_keyword                          |   13.22 |
| shopware67 | seo_url                                         |   27.70 |
| shopware67 | seo_url_template                                |    0.03 |
| shopware67 | shipping_method                                 |    0.09 |
| shopware67 | NULL                                            |  132.35 |
| NULL       | NULL                                            |  132.35 |
+------------+-------------------------------------------------+---------+
229 rows in set (0.01 sec)

You can see the size in MB of individual tables, and the “rollup” meaning the sum over all the tables combined. Here we have 132MB of data in the database, which is not very hard to all keep in memory. Even with the just 8 MB Buffer Pool from above, the Hit Rate is at 97% when just browsing through the UI, because not all of the 132 MB are needed.

Contrast with the Tideways database, which currently has 300 GB of data. It still achieves a nearly 100% cache hit rate, because the “working set” of data is much smaller. About 290 GB are historical data that are rarely read.

The query for the data size thus only gives the upper limit of what you might need for the InnoDB Buffer pool size. Each application is different with respect to what its “working data set size” is.

Changing the InnoDB Buffer Pool Size

Now to the final important piece, changing the value. This should be done on server startup and requires root access to the Database server, or maybe is changeable through the control panel of your hosting or cloud provider. There is most likely a mysqld.conf somewhere in /etc/mysql or a subfolder, this differs between Linux distribution.

In this file you either already find the setting, or you add the setting with the value you want and restart the MySQL server afterwards:

innodb_buffer_pool_size=16g

If you want to change the value at runtime without restart, that is also possible. But be aware that it reverts back to the configured server value when restarted without changing the configuration:


mysql> SET GLOBAL innodb_buffer_pool_size=16g;

See the MySQL documentation for more information on changing this value.

How we use hyperfine to measure PHP Engine performance

One of our recurring jobs at Tideways is to ensure that all of our instrumentation works with new and upcoming PHP versions. For us, “working” doesn’t just mean that the results are correct, but that your PHP extension is fast, gathering insights for our customers with a minimal performance overhead.

While we have a comprehensive automated test suite, especially for new PHP features or library updates, sometimes we manually investigate – and one of our main tools for this job is hyperfine.

What is hyperfine

Hyperfine is a command-line benchmarking tool that takes care of handling the challenging parts of performance measurement correctly. By presenting execution times in a reliable, consistent and human-readable way, it ensures clear communication of the results. Beneath the surface, hyperfine also takes care of all the minute details that pose a challenge during benchmarking, thereby making it easier to get good and correct results.

All you need to do is pass it different command invocations, and it will compare them using multiple executions to get reliable data..

Let’s start with a simple example. We run the sleep command for one, two and three seconds.


hyperfine 'sleep 1' 'sleep 2' 'sleep 3'

The output shows us that “sleeping for 1 second” was 1.98 ± 0.01 times faster than “sleeping for 2 seconds”. One of the things to note is that hyperfine always shows you the measurement uncertainty.

With sleep this uncertainty is very low, showing 1.020 s ± 0.006 s, meaning the execution is confidently measured to take 1.014 to 1.0026 seconds. Other programs can have a lot more variance and therefore uncertainty.

For example, when comparing 603.9 ms ± 170.8 ms and 502.1 ms ± 400.8 ms we can’t make any statement regarding what is actually faster, as the errors overlap too much. Being mindful of this can be very useful to focus on meaningful improvements.

Measuring PHP performance

Now, how does that work for PHP?

We test two main things: The same PHP code with different PHP binaries, for example different versions or build flags, and different PHP code with the same binary.

In the following test, we run a million sprintf calls using PHP 8.3 and PHP 8.4:



namespace bench;

$i = 10_000_000;

while($i--) {
    key('test', 123);
}

function key(string $type, int $identifier): string
{
    return \sprintf('last_ts_%s_%s', $type, $identifier);
}
hyperfine '/opt/php-8.3/sapi/cli/php bench-sprintf.php' \
     '/opt/php-8.4/sapi/cli/php bench-sprintf.php'

In cases where the command gets quite long, for example, when adding ini settings with -d we can also use Hyperfine’s placeholder parameter.

hyperfine -L version 8.3,8.4 \
  '/opt/php-{version}/sapi/cli/php bench-sprintf.php -d opcache.enable_cli=1'

Testing on the CLI: Be mindful of OPcache

By default, OPcache isn’t enabled for CLI scripts. This is done to avoid the cost of the optimization for one-time scripts. For our case, we want to use OPcache as this will provide the relevant insights for web-requests or long-running scripts:

hyperfine '/opt/php-8.3/sapi/cli/php -d opcache.enable_cli=1 bench-sprintf.php'  \
   '/opt/php-8.4/sapi/cli/php -d opcache.enable_cli=1 bench-sprintf.php'

Here we see PHP 8.4 executing 1.47 times faster, already an impressive difference, but we’re also still paying the cost of the function calls, which also adds some overhead.

So let’s isolate the change to just the sprintf call.



$i = 10_000_000;

while($i--) {
    sprintf('last_ts_%s_%s', 'abc', 1);
}

And see how the numbers change:

hyperfine -L version 8.3,8.4  \
   '/opt/php-{version}/sapi/cli/php -d opcache.enable_cli=1 \
   bench-minimal.php'

Over six times faster! Measuring things and making definitive statements is always tricky, and it’s easy to get things wrong. If we, for instance, forget to enable OPcache, the example above only shows a 1.8x difference between PHP 8.3 and 8.4 instead of the over 5x we’re seeing here.

When testing with PHP versions before PHP 8.5, it’s also possible that the OPcache extension isn’t loaded and PHP will silently ignore the OPcache ini settings, invalidating the results. Double-checking with php -m | grep -i opcache or with some code in the test script can prevent that issue.

This happens because of our simple test case. Now that PHP 8.4 can turn \sprintf into an interpolated string containing only literals, OPcache will recognize that this string isn’t used and optimize that out. Using a couple of variables as parameters or assigning the result to something will change this behavior. Try it out yourself! A lot of the value in benchmarking comes from being surprised and then learning something while trying to explain the results.

Benchmarking code chances

Now let’s make some comparisons within one PHP script:



namespace bench;

extension_loaded('zend opcache') || throw new \Exception('Missing OPcache');

$i = 10_000_000;

if ($_SERVER['argv'][1] === 'with_backslash') {
    $function = with_backslash(...);
} else if ($_SERVER['argv'][1] === 'without_backslash') {
    $function = without_backslash(...);
} else {
   throw new \Exception('Invalid parameter');
}

while($i--) {
    $function('abc', 1);
}


function with_backslash(string $type, int $identifier): string
{
    return \sprintf('last_ts_%s_%s', $type, $identifier);
}

function without_backslash(string $type, int $identifier): string
{
    return sprintf('last_ts_%s_%s', $type, $identifier);
}
hyperfine -L mode with_backslash,without_backslash  \
   '/opt/php-8.4/sapi/cli/php -d opcache.enable_cli=1 bench.php {mode}'

Over 1.3 times faster by adding a single character, or using the equivalent use function sprintf import.

Here, we can nicely see that the optimization only takes effect when PHP can see that sprintf is the global sprintf. Without the \ PHP needs to look up if a locally defined \bench\sprintf namespaced function exists, and thus PHP can’t optimize the call for you.

Note that this specific optimization is not performed by OPcache, but rather happens in the compiler itself. Other types of optimization are specific to OPcache though. Hence, we suggest you always turn it on to make sure your tests match how you will later run your code.

Outro

We hope this serves as a nice overview of benchmarking with hyperfine and showcases some key considerations to keep in mind when testing PHP.

An AI would never be able to dive this deep into technical topics surrounding PHP performance! Follow us on LinkedIn or X and subscribe to our newsletter to get the latest posts.

Let’s dive in and find out what is causing performance bottlenecks! Without Tideways, you’re likely to fish in murky waters attempting to figure it out. Try our free trial today and be enlightened.

Testing if Franken PHP Classic Mode is faster and more scalable than PHP-FPM

FrankenPHP bills itself as “The Modern PHP App Server” and provides a single-process solution to running PHP. Its worker mode allows to greatly increase the performance of cleanly developed applications that are compatible with it by saving on framework bootstrapping time.

Unfortunately, not every application is compatible with worker mode. Depending on the amount of global state kept by the application, updating it for worker mode is not always easy.

For completeness, we investigate if FrankenPHP in “Classic Mode” is also able to speed up your application. Prior research by Ivan Vulovic in https://vulke.medium.com/frankenphp-vs-php-fpm-benchmarks-surprises-and-one-clear-winner-173231cb1ad5 suggests it is – but the numbers seem a little too good to be true.

In this blog post, we aim to verify these results with the explicit goal of testing PHP runtime performance itself, and not the performance of PHP script execution, Linux scheduler, web server, or other influences. Our results show that there is essentially no performance or throughput difference between PHP-FPM vs. FrankenPHP Classic-Mode.

Benchmark Setup

As in our previous article comparing the performance of different PHP versions across popular applications, we’re using a Hetzner VPS with 8 dedicated vCores on an AMD EPYC 7003 or EPYC 9004 (Hetzner CCX33). This time, the operating system is the newly released Debian 13 (“Trixie”).

We’ll be comparing FrankenPHP v1.9.1 (PHP 8.4.12 Caddy v2.10.2) against nginx/1.26.3 paired with PHP-FPM 8.4.11. The measurements are performed using Vegeta v12.12.0.

We’re installing FrankenPHP as a Standalone Binary using the recommended curl |sh command. Both nginx and PHP-FPM are installed using Debian Trixie’s official package repositories.

After installation, we only make a small change to nginx’ configuration, adjusting the port to avoid conflicts and to enable the PHP configuration block provided by Debian.

At this point both FrankenPHP and the nginx+FPM combination run with their default configuration, without any performance tuning.

Given that we want to measure the overhead added or removed by selecting a different runtime environment and not the performance of PHP itself, we’re going to test with simple scripts that do not contain any actual business logic.

In addition, we are not testing with a lot of concurrency; instead, we spawn 1 worker per CPU. For the full Vegeta output including more throughput and response percentiles click on the “test label” in the first column of each result table below.

Testing HTML Response Generation

A common server-side rendered HTML landing page contains about 50 KiB of data, so let’s use this one as the basic benchmark:



header('content-type: text/html; charset=utf-8');

$str = str_repeat('x', 1023) . "\n";

for ($i = 0; $i < 50; $i++) {
	echo $str;
}
ResponseFPM rpsFrankenPHP Classic rpsFPM 99% msFrankenPHP Classic 99%
HTML7023.116934.062.022 ms2.06 ms

Across this 60-second benchmark with 8 clients hammering the server as fast as possible, the nginx+FPM combination was able to serve 7023 requests per second (with overall better latency), whereas FrankenPHP in classic mode could serve 6934 requests (with a little better maximum latency). Nginx+FPM are thus roughly 1.3% faster than FrankenPHP in classic mode. Not enough to really matter.

Case closed? Not yet. A landing page is just one of the cases you’re going to experience in your application. What if, for some reason, the results change for other use cases?

Testing Binary Response Generation

Instead of an HTML response, let’s try a binary response format, for example, PDF:

ResponseFPM rpsFrankenPHP Classic rpsFPM 99% msFrankenPHP Classic 99%
HTML7023.116934.062.022 ms2.06 ms
PDF7610.315368.851.828 ms4.246 ms

It seems that FPM got faster (with even better latencies) and FrankenPHP got slower, only by changing the content-type from text/html to application/pdf, keeping the response exactly the same:


header('content-type: application/pdf');

$str = str_repeat('x', 1023) . "\n";

for ($i = 0; $i < 50; $i++) {
	echo $str;
}

Let’s try again with an “encrypted” base64-encoded payload being embedded into our HTML. Encryption is simulated by random data:



$random = new \Random\Randomizer(new \Random\Engine\Xoshiro256StarStar());
for ($i = 0; $i < 50; $i++) {
	echo $random->getBytesFromString("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", 1023), "\n";
}
ResponseFPM rpsFrankenPHP Classic rpsFPM 99% msFrankenPHP Classic 99%
HTML7023.116934.062.022 ms2.06 ms
PDF7610.315368.851.828 ms4.246 ms
Random1940.055606.227.704 ms3.143 ms

Both FPM and FrankenPHP became slower, but that is expected since the script now does much more work. This time FrankenPHP beats FPM 2.89×. An odd result, that can be explained later in the blog post.

What if we try to do even less work than our landing page example rather than more work? Let’s try with a Hello World example.



echo "Hello World!";
ResponseFPM rpsFrankenPHP Classic rpsFPM 99% msFrankenPHP Classic 99%
HTML7023.116934.062.022 ms2.06 ms
PDF7610.315368.851.828 ms4.246 ms
Random1940.055606.227.704 ms3.143 ms
Hello World18478.6918403.810.904 ms0.877 ms

Both FPM and FrankenPHP are serving an impressive 18400 requests per second at a 0.4% difference in RPS.

Testing With Higher Concurrency

The article by Ivan Vulovic compared the performance with higher concurrency. While we believe this not to be particularly useful, since we would be measuring the performance of the operating system’s scheduler more than anything else, perhaps FrankenPHP is doing something to be more friendly to the scheduler. Let’s see with the Hello World example that is comparable to the tests done by Ivan Vulovic:

ResponseFPM rpsFrankenPHP Classic rpsFPM 99% msFrankenPHP Classic 99%
HTML7023.116934.062.022 ms2.06 ms
PDF7610.315368.851.828 ms4.246 ms
Random1940.055606.227.704 ms3.143 ms
Hello World18478.6918403.810.904 ms0.877 ms
Hello World Concurrency 10021847.6122675.249.742 ms23.365 ms

FrankenPHP serves roughly 3.7% more requests per second, but at a much worse latency distribution.

Testing with Optimized Configuration

Up until this point, we were running the “default configuration” for both FrankenPHP and nginx+FPM. Let’s look into some performance tuning.

FrankenPHP provides an official Performance Optimization section in the documentation. No actionable FrankenPHP-specific advice is provided, except for “Don’t use Musl”, which we are not.

For nginx and FPM, there’s no official guide that we are aware of, but we’ve made the following changes:

  • Disabled access logs in nginx (matching FrankenPHP).
  • Enabled fastcgi_keep_conn in nginx.
  • Configured pm=static and pm.max_children=16 in FPM (matching FrankenPHP’s num_threads).

Making these changes did not result in any differences worth mentioning.

Accounting for Output Compression Performance

So, how can all the above results be explained, especially the sudden deviations in the PDF and Random test scenarios?

It appears we were not actually measuring the performance or overhead of PHP-FPM vs. FrankenPHP, but rather the quality of the output compression implementation of nginx and Caddy, respectively.

Vegeta is setting a accept-encoding: gzip request header, and both nginx and FrankenPHP will automatically compress responses coming from PHP when the circumstances are right:

Is Webserver compressing?FrankenPHPnginx
HTMLyesyes
PDFnono
Randomyesyes
Hello Worldnoyes

In the case of the PDF example, the response will not get compressed with either FrankenPHP or nginx. In the case of nginx, skipping this compression seems to improve performance; FrankenPHP seems to handle this case of a larger response body not particularly well. On the other hand, nginx is not able to handle incompressible random responses well when attempting to compress them.

We could also confirm this by setting an accept-encoding: identity request header with Vegeta to disable the compression.

Using the landing page example with 8 concurrent requests shows numbers comparable to the PDF invoice when compression is disabled:

ResponseFPM rpsFrankenPHP Classic rpsFPM 99% msFrankenPHP Classic 99%
HTML7023.116934.062.022 ms2.06 ms
PDF7610.315368.851.828 ms4.246 ms
Random1940.055606.227.704 ms3.143 ms
Hello World18478.6918403.810.904 ms0.877 ms
Hello World Concurrency 10021847.6122675.249.742 ms23.365 ms
HTML No GZIP7456.475504.161.786 ms3.993 ms

We also did some final tests with HAProxy 3.0.11 instead of nginx as the FastCGI gateway in front of PHP-FPM and were able to squeeze out some additional RPS with even better latency compared to nginx, but not really enough to matter in practice.

Conclusion

As a conclusion, we can confidently say the differences in overhead between PHP-FPM and FrankenPHP in classic mode are not relevant enough to suggest moving to FrankenPHP Classic Mode is always better.

As with our previous benchmarks, we can conclude that your application’s architecture has the biggest impact on performance, that there is no shortcut that will magically make things faster.

Shaving off the last microsecond from PHP’s startup performance will not make an impact when spending several hundred milliseconds waiting for the database.

An APM such as Tideways can help find this kind of offender within your application code.

As for choosing a PHP runtime, use whatever your team is comfortable with already.

  1. When using Caddy with PHP-FPM, moving to FrankenPHP can reduce operations complexity.
  2. When you want to benefit from one of the FrankenPHP exclusive features, like 103 early hints, Go extensions, or Mercure realtime support, a move from PHP-FPM to Classic mode might make sense.
  3. In other cases PHP-FPM still has an edge in performance when your application code is already optimized but not yet ready for worker mode.

Performance Benchmark Report Q2 2025 for Shopware 6

How does your Shopware 6 store’s PHP backend performance compare to other operators of Shopware in general?

To answer this question, we have aggregated and anonymized performance data from over 200 Shopware 6 stores over the last quarter and computed benchmark numbers to compare to for the most important page types: Product details, Category, Search, and Homepage.

Executive Summary

  • For the Shopware 6 operators in the Top 10% and 25%, the numbers show what is possible if you operate with a focus on backend performance. The top 10% of shops perform in the range of 270 ms on average and 400 ms in the 95% percentile.
  • Comparing with the previous Q1 benchmark, we see the numbers in the top 10% improving for product and category pages. For search, performance degrades by 20%–40% in all cohorts.
  • The slowest 25% of shops have their pages perform around 1 second on average, and closer to 2 seconds for the 95%. This will affect the Largest Contentful Paint (LCP) scoring negatively.
  • The fastest and slowest 10% are 6–26 times apart, showing that there is tremendous potential in analyzing and optimizing PHP backend performance with tools like Tideways.

Benchmark of average response times

Page10% Best25% BestMedian25% Worst10% Worst
Product Details271 ms415 ms607 ms895 ms1599 ms
Category Page271 ms479 ms799 ms1215 ms1791 ms
Search303 ms639 ms991 ms1407 ms2943 ms
Homepage51 ms135 ms319 ms607 ms1343 ms

Benchmark of 95% percentile response times

Page10% Best25% BestMedian25% Worst10% Worst
Product Details431 ms671 ms991 ms1599 ms2815 ms
Category Page479 ms927 ms1535 ms2431 ms3583 ms
Search575 ms1023 ms1471 ms2175 ms5375 ms
Homepage99 ms271 ms575 ms1215 ms1983 ms

Methodology

The data foundation for this is daily average and 95% response times of PHP requests in Shopware 6 shops that have an active Tideways subscription or trial during the first quarter of 2025 and reported data to our backend. This is measured through the Tideways PHP extension, tapping into the PHP runtime.

From a Core Web Vitals perspective, this is an approximate benchmark for the Time to First Byte (TTFB) of all requests in the shop that are served by the PHP backend. Requests served cached from Varnish, Fastly or other HTTP cache in front of the shop are not included.

A shop must have had at least 5,000 storefront requests that day processed by PHP in the backend and 250 requests of the page type to be included. Only shops using the default controllers for the product details, category list, search, cart, and homepage page types are considered. This leads to 74,607 data points included in the report, over 67,711 from the last report.

Take these numbers with a grain of salt because they intentionally include Shopware users across all major PHP and Shopware versions, with Elasticsearch or MySQL-based search, with Shopware’s HTTP Cache enabled or using external HTTP caches (Varnish) and across all industries and customer personas.

See where your store ranks

To see where your Shopware 6 store is ranking, sign up to a free trial with Tideways and collect backend performance data. Tideways can recommend performance optimizations and provides effortless insights into all code-level performance problems.

Performance Benchmark Report Q2 2025 for Magento 2

How does your Magento 2 store’s PHP backend performance compare to other operators of Magento in general?

To answer this question, we have aggregated and anonymized performance data from over 70 Magento 2 stores over the last quarter and computed benchmark numbers to compare to for the most important page types: Product details, Category Page, Search, and Homepage.

Executive Summary

  • For the Magento operators in the Top 10% and 25%, the numbers show what is possible if you operate with a focus on backend performance. The top 10% of shops perform in the range of 500 ms on average and below 1 second in the 95% percentile.
  • The slowest 25% of shops have their pages perform around 2 seconds on average and above 3 seconds in the 95% percentile in Time to First Byte (TTFB). This will affect the Largest Contentful Paint (LCP) scoring, which turns from Good to “Needs Improvement” at 2.5 seconds. These shops have no chance of reaching a good result from these numbers, as TTFB is only one factor in LCP.
  • Without very high full page cache hit ratios, many visitors of Magento 2 shops will have suboptimal performance.
  • Compared to our Shopware 6 benchmark, these numbers look worse – but they are not comparable, as a larger number of Magento stores uses Varnish for cached pages compared to PHP-based full page caching in Shopware 6.
  • The fastest and slowest 10% are 5–15 times apart, showing that there is tremendous potential in analyzing and optimizing PHP backend performance with tools like Tideways.

Benchmark of average response times

Page10% Best25% BestMedian25% Worst10% Worst
Product Details671 ms895 ms1407 ms1983 ms3071 ms
Category Page639 ms927 ms1471 ms2303 ms3199 ms
Search431 ms735 ms1087 ms2175 ms3711 ms
Homepage191 ms543 ms863 ms1535 ms2943 ms

Benchmark of 95% percentile response times

Page10% Best25% BestMedian25% Worst10% Worst
Product Details895 ms1279 ms2047 ms3071 ms5631 ms
Category Page959 ms1407 ms2303 ms3583 ms6399 ms
Search543 ms1023 ms1599 ms3071 ms6399 ms
Homepage239 ms735 ms1151 ms2175 ms4351 ms

Methodology

The data foundation for this is daily average and 95% response times of PHP requests in Magento 2 shops that have an active Tideways subscription or trial during the first quarter of 2025 and reported data to our backend. This is measured through the Tideways PHP extension, tapping into the PHP runtime.

From a Core Web Vitals perspective, this is an approximate benchmark for the Time to First Byte (TTFB) of all requests in the shop that are served by the PHP backend. Requests served cached from Varnish, Fastly or other HTTP cache in front of the shop are not included.

A shop must have had at least 5,000 storefront requests that day processed by PHP in the backend and 250 requests of the page type to be included. Only shops using the default controllers for the product details, category list, search, cart, and homepage page types are considered. This leads to 26,951 data points included in the report.

Take these numbers with a grain of salt because they intentionally include Magento users across all major PHP and Magento versions, with Elasticsearch or MySQL-based search, with Magento’s HTTP Cache enabled or using external HTTP caches (Varnish) and across all industries and customer personas.

Upcoming Improvements

In the future we plan to include non-default controllers for these pages as well, as Magento 2 stores often use plugins with custom controllers for product or category pages.

See where your store ranks

To see where your Magento 2 store is ranking, sign up to a free trial with Tideways and collect backend performance data. Tideways can recommend performance optimizations and provides effortless insights into all code-level performance problems.

What are compiler optimized internal PHP functions and should you import them via use statement?

Every once in a while when browsing through open-source code, you will probably have come across internal functions that are either imported implicitly with use function array_map; like here in Doctrine or prefixed with the global namespace separator, for example \is_string($foo) like in Symfony.

Curious beings as we are, you might wonder as did I: Why are they doing this? Do function calls not automatically fall back into the global namespace?

Yes they do, you don’t technically need to use function or prefix with the global namespace separator.

But there is another technical reason why open-source libraries do this: A small number of PHP internal functions has compiler optimized versions that avoid a lot of the internal overhead of an internal function call in the PHP engine. You can find these functions in the zend_compile.c file of PHP.

You might raise the point that this sounds like a micro-optimization and you are correct.

For open-source code you can argue that it should be a matter of good style to provide the lowest overhead possible.

For your own application code, this optimization is less important but there are cases where this optimization does matter as well: When you call these compiler optimized functions hundreds of thousands of times and your application has low response times in the 20-200ms range.

Granted, these cases are rare – but its good to having heard about this optimization nonetheless.

Should we stop here? No! Two topics are interesting to look into with compiler optimized functions:

  • Benefiting from the optimization by relying on code formatting automation
  • Can we measure compiler optimized functions in a Profiler and how would that look like?

Automatically import compiler optimized functions using available tools

When you are using a modern auto-formatting tool such as php-cs-fixer or phpcs with phpcbf already, then importing these optimized functions doesn’t even require a lot of work and can be fully automated. If that is the case, you can benefit from this optimization at nearly zero costs, you don’t even have to know or think about it.

For php-cs-fixer there is the native_function_invocation rule with the default configuration setting @compiler_optimized:

php php-cs-fixer-v3.phar fix src/ --allow-risky=yes --rules=native_function_invocation

For phpcs you can use the SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly sniff from slevomat/coding-standard, but be aware that it does quite a bit more than just importing compiler optimized internal functions.

<!-- phpcs.xml -->
<ruleset>
    <rule ref="SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly">
</ruleset>

And then run:

php vendor/bin/phpcbf

How Profilers see compiler optimized functions

There is something to learn about PHP engine and how profilers work with compiler optimized functions. By implementing the short cut for these few functions in the engine, they also circumvent the hooks that profilers use to detect track the execution.

Take this simple bit of code that re-implements a small variation of str_repeat in userland code and uses strlen doing so:

<?php

namespace MyStandardLib;

function my_own_str_repeat($chars, $length)
{
    $new = '';
    for ($i = 0; strlen($new) < $length; $i++) {
	    $new .= $chars;
    }
    return $new;
}

$str = my_own_str_repeat("x", 100000);
echo strlen($str) . "\n";

If we run this through Tideways Callgraph Profiler, Xdebug or any other Profiler you can spot the strlen functions being executed 100.000 times. Because of the missing import from the global namespace, the engine cannot use the compiler optimized version, since a user defined strlen might exist in the MyStandardLib namespace.

See how Tideways already gives you a hint about a potential optimization here, only because it would make an impact on the bottom line of this script, measured at 24% of total execution time.

As a side note: If you are wondering about the shart drop from 7,24ms to 1,81ms, this must be attributed to the profiling overhead that I have written about elsewhere, caused by an otherwise quick internal function called an excessive number of times.

As soon as you add a leading namespace slash to import the function the Profiler does not observe the execution anymore. It looks like my_own_str_repeat is a function that has no child calls. In its 1,24ms execution somewhere the optimized 100.000 strlen functions are hidden:

The Profiler will not be able to detect that strlen is even called anymore, on the account of the PHP engine’s shortcut.

So should you import functions that the compiler can optimize for performance reasons? The answer is: It depends. When the overhead is significant then yes, otherwise you could ignore this as a being too micro an optimization.

What’s next?

  1. Sign Up for our Newsletter if you don’t want to miss the next post on our blog.
  2. Start your 14 days free trial of Tideways for effortless performance insights into your PHP application.

Autoloading Performance – Avoid These 5 Mistakes!

Autoloading performance affects every PHP application. And no, it’s not a solved problem just because Composer handles autoloading for you nowadays.

In fact, for large applications like Magento, Shopware, or those based on Symfony or Laravel, autoloading can turn into a major performance bottleneck. More than 100 ms per request is not uncommon, caused by one of these common mistakes:

Let’s break it down.

What Is Autoloading Performance?Autoloading performance is distinct from PHP’s compile performance and interacts closely with OPcache.

When PHP encounters a class that hasn’t yet been loaded, it triggers the autoloader system. This involves calling callbacks registered via spl_autoload_register(). These callbacks are executed in sequence until the class is found or PHP throws a fatal error.

Each time this happens, PHP spends time:

  1. Running the autoloader callback(s)
  2. Calling the constructor of classes
  3. Compiling the class (unless already cached in OPcache)

In this blog post we are going to look at point 1: time spent in autoloader callback(s). And while Composer is responsible for this in modern PHP applications, there is still a lot that can go wrong.

As for point 3: Time spent compiling can be reduced to effectively zero by correctly using OPcache as detailed in this post on our blog.

1. Not Dumping a Class Map

Using classmap-based autoloading can significantly reduce overhead.

With Composer, you can optimize autoloading using:

composer dump-autoload --optimize

This generates a autoload_classmap.php file with a precomputed map of class names to paths. The autoloading code then has a shortcut that greatly reduces the runtime of autoloading.

Without a classmap, Composer must run much more complicated code to compute the path for every class and, most importantly, use an expensive file_exists check.

While it seems like an obvious optimization that is well-known, we still see 10-20% of customers are not optimizing autoloaders for production apps, and it would usually cost you 10-50ms in every request, and sometimes even more.

Tip: Always dump your classmap after potentially generating classes in a compile step. This is especially important in frameworks like Magento that auto-generate hundreds of classes.

2. Loading Unused Classes

Autoloading everything on every request is wasteful.

This happens often due to:

  • Eager Dependency Injection
  • Event systems loading all listeners
  • Route matchers loading every controller

Solution: Introduce lazy-loading or cutoffs to prevent loading unnecessary parts of the object graph.

Symfony framework is a good example where this work has happened under the hood over the last major versions. It’s EventDispatcher includes several optimizations where you don’t have to instantiate all listener objects for all events in every request. It does this by passing a factory closure as a listener instead:

// This code is what the Symfony Dependency Injection container performs
// under the hood when registering a listener.
$eventDispatcher->addListener(
    KernelEvents::REQUEST,
    function ($event) {
        // only when here, MyListener and ExpensiveDependency trigger autoloading
        // and the time of calling their constructor is spent.
        (new MyListener(new ExpensiveDependency))->onKernelRequest($event);
    },
);

// dispatch "unwraps" the listener by calling the closure, only then MyListener
// and the ExpensiveDependency are created. 
$eventDispatcher->dispatch(
    new RequestEvent(/** ... */), KernelEvents::REQUEST)
);

With potentially hundreds of listeners and each of them having many dependencies, this can prevent a lot of autoloading happening for classes that ultimately will not be used in the concrete request.

Bonus: PHP 8.4 introduces native lazy-loading support for DI containers, which will greatly improve this situation in the future. See my YouTube video series on Native Lazy Objects in Doctrine (part 1), Symfony (part 2), and Magento 2 (part 3).

3. Expensive Prepended Custom Autoloaders

A chain of multiple autoloaders can be tricky for performance. Composer’s autoloader should ideally be first in the chain—it resolves ~95% of classes quickly with the generated classmap. Then more custom and specialized, potentially expensive autoloaders can run afterward.

Custom autoloaders that prepend themselves to Composer can slow everything down.

Case in point: the older, now abandoned Laminas package laminas-zendframework-bridge prepended an autoloader before Composer’s autoloader to simplify the ZendFramework to Laminas Rename Migration. That package significantly increased autoload times and automatically activated itself as a second-level dependency when using any kind of Laminas package.

Use spl_autoload_functions() to inspect, deactivate, replace, or reorder autoloaders if needed.



require_once 'vendor/autoload.php';

$functions = spl_autoload_functions();
foreach ($functions as $function) {
    if ($function instanceof \Closure) {
        $reflection = new ReflectionFunction($function);
        // check if autoloader closure is from laminas zendframework bridge
        if (str_contains($reflection->getFileName(), 'laminas-zendframework-bridge')) {
            spl_autoload_unregister($function);
        }
    }
}

4. Building Your Own Preloading Scripts

Preloading libraries manually was popular before Composer matured. Think: Doctrine 1’s “All-in-One” file or Symfony 2’s early class loaders.

But today, these preloading scripts are largely unnecessary and often counterproductive:

  • They load too many classes upfront, requiring memory, which costs performance.
  • They clutter stack traces and make debugging with XDebug harder
  • They reduce flexibility

Modern DI containers like Symfony’s use smarter preloading based on actual runtime needs.

5. Not Using OPcache Preloading

OPcache preloading is a powerful built-in feature since PHP 7.4.

It allows loading frequently-used classes once at PHP startup, keeping them in memory across all requests.

Benefits:

  • Skip autoload callbacks entirely
  • Reduce file I/O
  • Improve response times by 5–10% for fast endpoints

For high-performance apps, OPcache preloading gives an additional edge that you don’t want to leave on the table. See my previous blog post on OPcache preloading for details.

Conclusion

Composer is a fantastic tool, but it doesn’t prevent autoloading from becoming a performance drag.

Avoiding the five mistakes above can save tens of milliseconds per request, which adds up fast.

Want more PHP performance tips? Subscribe to our newsletter or check out the full video for a deep dive with real-world examples.

Stay fast!

What’s next?

  1. Sign Up for our Newsletter if you don’t want to miss the next post on our blog.
  2. Start your 14 days free trial of Tideways for effortless performance insights into your PHP application.