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.

Should You Use OPcache Preloading in Your PHP App?

Thinking about enabling OPcache Preloading to squeeze more performance out of your PHP application?

It’s an advanced feature with very specific benefits — and in most cases, you likely won’t notice a difference.

In this post, we’ll explain what OPcache Preloading is, how to set it up, and when (or if) it makes a measurable impact.

What is Preloading?

Preloading was introduced in PHP 7.4, contributed by Dmitry. It allows PHP scripts to be loaded and compiled once at process startup (before any requests), so functions and classes are ready immediately — as if they were built-in.

This is different from regular OPcache, which caches PHP scripts after first execution. Preloading complements OPcache by eliminating autoloading overhead and repeated include/require calls.

The main downside? PHP-FPM or Apache startup time increases, because all preload scripts are executed at once.

When Preloading Makes Sense

Preloading has a few technical pre-requisites:

  • Your app is the only one on the server or container
  • You’re using PHP-FPM or mod_php
  • You can provide a custom preload script

It does not help in setups like Laravel Octane, Roadrunner, FrankenPHP, or AWS Lambda. In fact, it may shift cold start time into boot time with no real net gain.

How to Set Up Preloading

  1. Write a preload script This should require all files you want loaded. Symfony does this out of the box. Laravel has third-party support. Or you can write your own. The PHP documentation on Preloading explains the requirements for such a script.
  2. Configure php.ini to enable preloading:

When your script from step 1 is called “preload.php” you need the php.ini to point to it. In addition you need to configure the username this script is run with. This should be the same user as the web-server or FPM is running under.

opcache.preload=/app/preload.php
opcache.preload_user=www-data

3. Restart PHP-FPM or Apache Check PHP logs and error tracking (e.g., Tideways) to ensure everything loads cleanly.

What Performance Gains Can You Expect?

In a typical Symfony or Shopware app, autoloading takes around 10–16ms. If the response time of your application is 1 second, preloading might save less than 1%. That’s not worth the complexity.

However, in fast apps that response below 100ms, the story changes.

Example: Tideways Ingest API

We tested preloading on our own application using the pre-generated Symfony preloading script.

The Ingest API receives 30k requests every minute with performance data from our customers. It just authenticates the sender data and then passes it on to a message queue for processing. We expect this API to be very fast to handle the high request volume.

In that case optimizing autoloading performance further will make sense. Before the preloading change we had a 95% percentile response time of 20ms. On average 1ms was spent in autoloading on every request.

After we enabled preloading the 95% response time dropped to 18ms and autoloading time dropped to 0.

That’s a 10% gain.

OPcache Preloading

It will allow us to handle the more of this traffic with fewer resources and save money on server resources.

Conclusion

Preloading isn’t a universal remedy — but in performance-critical, high-throughput environments, it can be worth it.

Use preloading if:

  • Your app is well-optimized
  • Response times are already low (<100–200ms)
  • You want to reduce CPU load at scale to save server resources and increase throughput

For apps with high response times, the relative benefit is negligible. Focus on bigger bottlenecks first.

Need help deciding whether preloading is worth it for your app? Tools like Tideways can measure autoloading and compilation time directly.

A Multi-Step Strategy for better Full Page Caching

A full page cache is a powerful performance optimization for web-applications with numerous visitors.

Instead of performing all the PHP and database work to render the same page over and over again, you cache the resulting HTML once and serve that time and again for a period.

But there is room for even more improvement, especially in e-commerce applications that use full page caching.

Edge Side Includes (ESI) is a standard for full page caching that has a clever approach to the problem of multi step caching. It allows storing different parts of a page as different cache entries in such a way that they can even have different cache lifetimes and dynamic invalidation without affecting each other.

The upcoming Shopware 6.7 moves from a single-step full page cache to using ESI for header and footer navigation, serving as an interesting real-world experiment to observe the benefits.

Single-Step Full Page Caching

Traditionally, with a full page cache, the complete HTML Output is either cached or not cached at the time of a new request coming in:

In the case of a cache miss, database and external systems such as Elasticsearch are queried to generate the response dynamically.

The output is then cached and on the next request to that same product page, most database, and external system calls as well as all processing of the data to HTML is skipped.

This usually leads to a huge performance improvement as well as a conscious use of processing resources.

In e-commerce applications, frequent changes in stock, prices, or dynamic features like segmentation often require invalidating the cache for product and category pages – leading to a low cache hit rate and frequent full page regeneration.

Generally, there are three solutions to improve this situation:

  1. Put in effort to reduce the number of cache invalidations, and the number of different cache entries due to customer segmentation. This is usually the most technically challenging approach because it requires in-depth understanding of the concrete requirements of the shop application you are optimizing.
  2. Warmup the cache using a background process. Whenever an entry gets invalidated, notify a background process that regenerates this page into the cache. This effectively offloads the burden of the “slow page” to affect only a robot, not a real user. But it requires plenty of computational resources to pull off, and will come at the wasteful cost of generating a lot of cached pages that are never looked at by a real user.
  3. Accept the low page cache ratio, and substantially reduce the response time for uncached pages. There is an even more radical approach here, where you build the shop application specifically with performance of product and category pages in mind, so that no caching is required at all.

Before you do all that, there are some simple first steps when trying to reduce the response time for cached pages that even a generic shop framework like Shopware (or Magento, WooCommerce for that matter) could implement first.

With multi-step caching, you can reduce the time to generate the uncached page by re-using HTML for common parts across all pages.

Conceptually, every web page can be grouped into different sections that show different high-level or detailed content of the site. The four main sections are usually header, sidebar, main content and footer.

In the context of full page caching, it’s only logical to think about caching these four sections separately.

In the case of a Shopware application and their default layout, the header section is the most interesting section because it contains the navigation menu that is known to take a significant amount of time to render – 50ms, 100ms, but also high three-digit sums are not unusual.

Then every time an uncached product or category gets rendered, at least we don’t incur the performance loss of rendering these common sections over and over again.

The tricky part to solve is cache expiry and invalidation of these sections. If the header cache entry expires because a category entry in the navigation gets deleted, for example, do all full page cache entries need to get invalidated as well?

This problem is solved by Edge-Side Includes. Instead of storing the sub-section into the output of the fully cached page, it caches only a reference to it using a special <esi:include> tag.

When rendering the full page, the software that implements the ESI rendering checks for these references and dynamically builds together the final output from the different cache entries. This could be Symfony HTTP Cache or Varnish for example.

This comes with two benefits: Invalidation of a sub-section such as the header does not require invalidation of all cache entries that make use of it.

Cache lifetime of the sections can be different, and the user still always sees the most recently cached output of every section of the page.

How it’s implemented in Shopware 6.7

Shopware is using the Symfony framework under the hood. This enables using either a PHP-based implementation or Varnish for the full page caching with ESI support.

The entry point for this functionality can be found in the base.html.twig template:

{{ render_esi(url('frontend.header', { headerParameters: headerParameters })) }}

The frontend.header route maps to a new controller action NavigationController::header:


#[Route(path: '/header', name: 'frontend.header', defaults: ['XmlHttpRequest' => true, '_httpCache' => true, '_esi' => true], methods: ['GET'])]
public function header(Request $request, SalesChannelContext $context): Response
{
    $header = $this->headerLoader->load($request, $context);

    $this->hook(new HeaderPageletLoadedHook($header, $context));

    return $this->renderStorefront('@Storefront/storefront/layout/header.html.twig', [
        'header' => $header,
        'headerParameters' => $request->get('headerParameters') ?? [],
    ]);
}

And the actual performance gain is then visible from a code-perspective in the diff for GenericPageLoader::load where all the code for loading and rendering the navigation was removed. When header and footer are cached, then all this processing is skipped because HeaderLoader::load and FooterLoader::load are not called anymore.

Measuring the Performance Impact

Looking at the upcoming Shopware change to its full page cache, we can compare the performance impact for a demo application to get a rough gauge for real-world impact.

Comparing two aggregated traces with callgraph data for 100 profiling traces on uncached product pages with Shopware 6.6 and 6.7 we can see a 110ms improvement for our demo shop: 65,3ms saved not rendering the navigation Twig template and 45.7ms loading and processing the category and navigation data from the database.

This is a significant improvement that will directly benefit all Shopware shops. Using ESI for header and footer is a simple and safe way to make full page caching more effective and I am keen to see the impact this change has on our Shopware benchmark report over the next quarters when people upgrade to version 6.7 of Shopware.

For you readers not using Shopware, this should hopefully serve as a good starting point on how to implement a full-page cache with muilti-step rendering to get a better overall cache hit ratio and faster responses for partially uncached pages.

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.

Combining regular expressions with named capture groups to improve performance

Regular expressions are usually not top of mind when you think of performance bottlenecks in your application, but inefficient patterns or sub-optimal use of the APIs can make them slow.

A wonderful learning opportunity for this is a recent roughly factor of 2x performance improvement patch to Doctrine DBAL’s SQL Parser. This patch was submitted by Soner from Shopware fame, thank you to him.

Doctrine DBAL uses the SQL Parser to expand a single prepared statement parameter to a list of values, for example for a WHERE id IN (?) query.

SQL Parser Before the Patch

Before version 3.10 of Doctrine DBAL, the SQL parser used to iterate over 4 different regular expressions, and when one of them matched, the corresponding code was called. This was coded as an array of regular expression mapping to a closure:


/** @var array $patterns */
$patterns = [
    ':[a-zA-Z0-9_]+' => static function (string $sql) use ($visitor): void {
        $visitor->acceptNamedParameter($sql);
    },
    '(? static function (string $sql) use ($visitor): void {
        $visitor->acceptPositionalParameter($sql);
    },
    $this->sqlPattern => static function (string $sql) use ($visitor): void {
        $visitor->acceptOther($sql);
    },
    '[:\?\'"`\\[\\-\\/]' => static function (string $sql) use ($visitor): void {
        $visitor->acceptOther($sql);
    },
];

These patterns where checked in a loop and whenever one of them matched, they were iterated over again from the beginning:


while (($handler = current($patterns)) !== false) {
    if (preg_match('~\G' . key($patterns) . '~s', $sql, $matches, 0, $offset) === 1) {
        $handler($matches[0]);
        reset($patterns);

        $offset += strlen($matches[0]);
    } else {
        next($patterns);
    }
}

In a callgraph we can see that for 12 calls to Parser::parse, we have hundreds of calls to preg_match, key, current, next and reset and closures. Tideways has custom instrumentation for preg_match calls and shows a few starting characters of each regular expression, so that you can identify their performance individually.

A lot of potential for improvement there that this pull request captured.

The Patch

The patch rewrites the four regular expressions into a single one, using named capture groups to differentiate which one was matched:


$this->tokenPattern = '~\\G'
    . '(?P' . self::NAMED_PARAMETER . ')'
    . '|(?P' . self::POSITIONAL_PARAMETER . ')'
    . '|(?P' . $this->sqlPattern . '|' . self::SPECIAL . ')'
    . '~s';

while ($offset < $length) {
    if (preg_match($this->tokenPattern, $sql, $matches, 0, $offset) === 1) {
        $match = $matches[0];
        if ($matches['named'] !== '') {
            $visitor->acceptNamedParameter($match);
        } elseif ($matches['positional'] !== '') {
            $visitor->acceptPositionalParameter($match);
        } else {
            $visitor->acceptOther($match);
        }

        $offset += strlen($match);
    }
}

The syntax ?P<name> creates a named capture group that is then available in the $matches result array as a key. Its an empty string if it did not match, and a non-empty string if it matched.

This change removes the layer of indirection with the closures that call methods on the Visitor and cuts down on function calls for array traversal next, key, current and reset.

Measuring the Impact: 1,7x to 2,43x faster

When benchmarking the test-script from the PR with hyperfine the results speak for themselves, an improvement by a factor of 2,43x:

And comparing two callgraph profiles from before and after the change, we also see a 10ms drop from 24ms for an improvement by a factor of 1,7x.

You can also see how the closure calls and individual preg_match‘s are replaced by the single call to preg_match in the comparison of child functions.

The individual improvement depends on how often Parser::parse is called and how long the individual queries are that are parsed. The more characters a query has, the more significant the improvement becomes.

Combining regular expressions into one big expression is a common performance improvement, and libraries such as nikic/fastroute, symfony/routing or JayBizzle/crawler-detect make good use of it as well.

Got a tingly feeling for more insights on PHP performance, operations and debugging topics? Sign up for our newsletter.

Suffering from a slow web application and scratching your head as to why? Our PHP Profiler can help you out. Start a 14 day trial to get effortless performance insights from us, tailored to your application problems.

Lazy Loading Data Objects in PHP 8.4 with Doctrine ORM Example

PHP 8.4 includes a rather technical RFC called “Lazy Objects” which adds lazy loading functionality directly into PHP’s object model.

 In this blog post, I am going to explain what lazy loading is and how Doctrine implements it. I will show the approach the ORM took before PHP 8.4 and how the new Lazy Objects RFC improved the implementation.

By the end of the post, you will have some ideas on how to leverage this new feature for performance gains in your own application.

What is Lazy Loading?

Lazy loading is a pattern in programming that leaves the code to believe it interacts with an object of a certain type, however that object has not been fully initialized yet.

Think of lazy loading as deferring the constructor/factory of an object to a point where it’s actually used.

This allows passing around an object as a placeholder through the code, when you don’t know up front if it’s going to be used at all.

The most common example for this are database or cache connection objects, for example the connection in Doctrine DBAL:

namespace Doctrine\DBAL;

class Connection implements ServerVersionProvider
{
    protected ?DriverConnection $_conn = null;

    public function executeStatement(/** .. */): int|string
    {
        $connection = $this->connect();
        // ....
    }
    
    protected function connect(): DriverConnection
    {
        if ($this->_conn !== null) {
            return $this->_conn;
        }
        // ...
        $this->_conn = $this->driver->connect($this->params);
        // ...
        return $this->_conn;
    }
}

It connects to the database using a driver once, only when used for the first time, for example in executeStatement. Many other methods also call connect to obtain the connection, which will either open it – if called the first time – or return the already open connection.

Lazy Loading in Doctrine ORM

Doctrine ORM maps database rows to PHP classes. Database tables have relationships, that Doctrine represents as references between objects.

And this is where lazy loading comes into play. With a simple example, like Post on a message board, referencing the User that posted, it is modeled as a reference:


class Post
{
    public ?int $id = null;
    public string $message = '';
    public User $author;
}
class User
{
    public ?int $id = null;
    public string $name;
}

When you want to create a list of Post objects from the database, Doctrine doesn’t want to immediately load all associated authors for performance reasons.

Because Doctrine keeps data objects free from direct dependencies on the ORM library, it’s not possible to use an abstract base class that Post and User have to extend from to implement lazy loading.

Lazy Loading with PHP Engine Hacks and Code Generation

Up until PHP 8.4, Doctrine used code generation to implement lazy loading, by using a clever combination of engine level hacks and code generation.

  1. Generate a class UserProxy that extends User. Instantiate Post::$author with a UserProxy instead of a User object.
  2. unset() the declared properties of an object in the constructor of the proxy
  3. Implement a magic method __get interceptor that gets called when unset property is accessed, in our example User::$name
  4. Load all properties of the entity from the database and set the previously unset properties with their actual values.

The generated code for the proxy would roughly look like this (this is simplified for readability):



class UserProxy extends Proxy
{
    public function __construct(BasicEntityPersister $persister, int $id)
    {
        $this->_persister = $persister;
        $this->id = $id;
        unset($this->name);
    }
    
    public function __get($proprtyName)
    {
        $this->_persister->loadById(['id' => $this->id], $this);
        $this->_persister = null;
        return $this->$proprtyName;
    }
}

There is a lot more nuance to it that I want to spare you from, but if you wish to dive even deeper, feel free to look at LazyGhostTrait in Symfony VarExporter and Doctrine ProxyFactory using it.

There are a few downsides to this approach:

  • It requires a lot of complex code to get to work, relying on a few engine level hacks.
  • It prevents users from doing certain things with their classes (can’t be final, for example)
  • It can lead to weird behavior in meta programming, get_class($proxy) would not return User for example
  • It requires a “compile step” and decisions when and where to place the generated proxy code in your production setup.

Lazy Loading with PHP 8.4

Lazy Loading becomes a core level feature in PHP 8.4, and you can create a lazy instance of an object through ReflectionClass::newLazyGhost now.

A lazy ghost is initialized when one of these things happens: lazy property read/write, ReflectionProperty::set(Raw)Value/get(Raw)Value and a few more.

Creating a proxy in Doctrine ORM 3.4 is just a few lines and requires no code-generation anymore:



public function getProxy(string $className, array $identifier)
{
    $classMetadata   = $this->em->getClassMetadata($className);
    $entityPersister = $this->uow->getEntityPersister($className);

    $proxy = $classMetadata->reflClass->newLazyGhost(
        function (object $object) use ($identifier, $entityPersister): void {
            $entityPersister->loadById($identifier, $object);
        }
    );

    foreach ($identifier as $idField => $value) {
        // code "unabstracted" for easier understanding
        $classMetadata->getReflectionProperty($idField)
            ->setRawValueWithoutLazyInitialization($proxy, $value);
    }

    return $proxy;
}

It creates a lazy ghost with an initializer that calls EntityPersister::loadById to fetch from the database and load all values into the proxy object.

The identifier properties of the object are already set to their known values. This way, accessing them does not trigger the initialization of the object.

In the following example, the User of the Post is only loaded when User::$name is accessed:



$post = $entityManager->find(Post::class, 1);

echo "Post Id: " . $post->id . PHP_EOL;
echo "Message: " . $post->message . PHP_EOL;
echo "Author ID: " . $post->author->id . PHP_EOL;

// lazy loading of Post::$author is triggered only when Author::$name called
echo "Author Name: " . $post->author->name . PHP_EOL;

How cool is that?

The best part is going to be, that as a next step Doctrine can implement partial objects in a way that they can load the rest of the properties when they are accessed. This will unlock marking properties as lazy, for example very large binary or content objects (blobs, clobs) that should not be loaded with an entity automatically.

Hungry for more insights on PHP performance, operations and debugging topics? Sign up for our newsletter.

Suffering from a slow web application and scratching your head as to why? Our PHP Profiler can help you out. Start a 14 day trial to get effortless performance insights from us, tailored to your application problems.

What is the best value for max_execution_time in PHP?

By default PHPs maximum execution time is set to 30 seconds of CPU time. Since your webserver has a maximum limit of requests it can handle in parallel, this leads to an interesting question or performance and webserver throughput.

Is the maximum execution time of 30 seconds too large or too small?

In general: Yes, we recommend to decrease max_execution_time to the range of 5 or 10 seconds. But like so often, there is no one size fits all solution.

Why does everyone lean to increase max_execution_time though? If you search for this configuration on Google and YouTube you find posts that discuss exclusively how to increase it without talking about the implications.

The use-case is often that a single page starts to run into the execution time limit and the easiest option is to increase the global limit across all PHP processes and pages: Its just a quick configuration change after all.

If your webserver is near its limit of concurrent requests, a script taking 30 seconds can block 300 fast requests that only take 100ms and lead to HTTP 503 errors sent from the webserver or queueing delays. The available throughput per minute decreases massively for your given amount of PHP processes if you allow long requests.

Impact of Slow Requests on Throughput

The following example shows the throughput difference for a hypothetical PHP setup with 3 parallel processes:

In the worst case a stampede of users accessing slow parts of your site clogs up the available PHP processes and other users get served with HTTP 503 Service unavailable errors.

These conflicting scenarios show that a differentiated view is propably the best approach by configuring the max_execution_time differently for different endpoints and request types.

Strategies to safely allow high max execution time scripts

There are a few strategies to address the competing constraints of decreasing the max_execution_time in general, and allowing some scripts to have longer run times.

  1. Set max_execution_time just for a single or few pages.
  2. Use multiple PHP-FPM pools with different configurations
  3. Implement a waiting list or a locking mechanism that restricts slow scripts to run at most once in parallel.
  4. Offload slow work to a message queue

Programmatic Configuration based on Request

The max_execution_time INI setting can be changed at PHP runtime, so it makes sense to have a central place in your code-base that determines its value based on the request data (URL, method, for example):

<?php
// Symfony Http Foundation based example, also possible through $_SERVER
use Symfony\Component\HttpFoundation\Request;

$request = Request::createFromGlobals();

ini_set('max_execution_time', 5);

if ($request->getMethod() === 'POST') {
    ini_set('max_execution_time', 10);
}
if (str_starts_with($request->getPathInfo(), '/admin')) {
    ini_set('max_execution_time', 15);
}

Example: max_execution_time on Tideways app

We recently came across this problem in Tideways, where a small number of relatively unimportant reporting requests jammed the many important short API endpoints that accept data.

So there is no single perfect value, instead we solved this problem by applying the following heuristics when configuring our execution timeouts:

  1. Read requests (GET, HEAD) now have a lower default timeout, because they usually don’t cause inconsistencies when they are aborted by a timeout fatal error and usually make up a large amount of the apps traffic.
  2. Write requests (POST, ..) default to a higher timeout, to avoid inconsistencies when the request is aborted during the execeution.
  3. We measured the latency of all endpoints, especially 95%, 99% percentiles, the maximum duration and the number of requests to get a feeling for what the best default values could be. Tideways itself collects these values over larger timespans and per endpoint.
  4. We increased the timeout for endpoints that can take longer than the default timeout as long as they are low traffic endpoints. Otherwise we need to optimize their performance.
  5. We decreased the timeout for endpoints that are fast, but have a very high share of requests. These requests could quickly jam the webserver resources when their performance decreases.

These are pretty basic heuristics. There are more sophisticated solutions when you need to handle more complex timeouting cases that:

  1. If you have requests that are often slow and are still executed in high numbers, then consider executing them in their own PHP FPM pool with their own max number of parallel requests. This way they don’t affect other requests when requested in high numbers.
  2. If you have endpoints that could write inconsistent data, but you want to limit their execution time to a low timeout anyways, consider writing your own timeout logic to have full control about potential cleanup.
  3. Often slowness is caused by external services (HTTP) or databases (MySQL), in which case we would solve the problem by specifying client side timeouts that can be gracefully handled.

I will write about each of them individually in the next weeks. If you are interested make sure to sign up for the newsletter to get updated when the posts are published.

Using Cache-Control headers in Laravel for HTTP caching with Cloudflare

We recently helped our customer Holocafé prepare its Laravel application for a TV appearance on the German edition of Dragons’ Den (Höhle der Löwen) and the corresponding peak in traffic. The primary change was to utilize Cloudflare as an HTTP reverse proxy to cache the main page of the site and a few other mostly static pages that users were most likely to click on.

The expectation was that 80-90% of the curious users could be served with pages from the HTTP cache.

PHP applications can cause quite a bit of strain on your server resources, which is especially true when the main pages of your site contain static or slowly changing content that is the same for all users, and the same HTML output is generated over and over again.

A reverse proxy is capable of caching the HTML output for a certain duration and serving users the identical HTML repeatedly from its cache, without any impact on your servers. 

This prevents a significant amount of PHP processing from occurring, and it also prevents the clogging of the PHP-FPM maximum number of children that are required to serve more significant dynamic requests.

What is the Cache-Control header, and how does it work?

Since version 1.1 (1999), the HTTP protocol defines a header ‘Cache-Control’ that allows an HTTP response to signal to clients how they can cache it.

In its simplest form, you declare the response to be public, and a maximum age in seconds that clients are allowed to cache the response. In our case, we wanted to cache pages in Cloudflare for one hour, which this HTTP response header achieves:

Cache-Control: public, max-age=3600

Cloudflare or other HTTP reverse proxies, such as Varnish, will receive this response and cache the HTML on their servers.

For this to work, the responses have to be truly public. There is not a single output component that is allowed to be dynamic based on the user’s request: This means no Cookies, Language based on Header, Session, or other dynamic output based on time or other external sources.

Introducing a Cache-Control Middleware

In Laravel, you can add a middleware to add the Cache-Control header for selected controllers. For the existing Holocafe application we were unable to add this via middleware groups due to refactoring time constraints, but I would recommend using middleware groups to apply this middleware to only those public, deterministic routes where the cache-control header should be set.


namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class CacheControl
{
    public function handle(Request $request, Closure $next)
    {
        if (env('APP_CACHE_CONTROL', false) !== true) {
            return $next($request);
        }

        $response = $next($request);

        $path = $request->getPathInfo();

        if (isset($this->cachePathMinutes[$path])) {
            $cacheSeconds = $this->cachePathMinutes[$path] * 60;
            $response->header('Cache-Control', 'public, max-age=' . $cacheSeconds);
        }

        return $response;
    }

    private array $cachePathMinutes = [
        '/' => 60,
        '/de/aachen' => 60,
        '/de/duesseldorf' => 60,
        '/de/koeln' => 60,
    ];
}

Session Middleware incompatible with Cache-Control

For security reasons, it is plausible to assume that the page is no longer considered “public” once the session is initiated. But this Laravel application had the session middleware active on all routes and pages. To work around this, we have to disable the StartSession middleware for the cacheable public pages. With middleware groups, this would be simple, just do not apply the middleware to the public routes. 

Without middleware groups it required a few hacks:

  • Introduce a CacheableStartSession middleware that extends the conventional StartSession middleware.
  • Patch laravel/jetstream to avoid ShareInertiaData middleware to run when no session is initiated.
  • Extend Inertia Middleware to skip running when the session is not started.

If you are running into this problem yourself, see our patch to modify these packages. We blogged about using composer patches in a different scenario before.

Inertia incompatible with Cache-Control

The Holocafe Laravel application uses Inertia for the frontend. This implies that the frontend JavaScript code hijacks links and does not perform regular page loads when clicking a link. Instead, it fetches the page via AJAX and replaces only parts of the layout. This is a common pattern that makes websites and page traversal feel faster. However, due to the inability of Cache-Control responses to distinguish between Inertia and non-Inertia requests, the caching mechanism is compromised.

The solution was to use regular, non-Inertia powered links for the traversal between cached public pages.

Application features incompatible with Cache-Control

In the end, there were some features of the Holocafe website that were incompatible with Cache-Control as well. We introduced a feature flag for “cache control enabled” that automatically disabled these features for the short time frame that we needed caching.

Cloudflare requires explicit Cache Rules to actually cache text/html responses

When we rolled all this out we struggled for a while, Cloudflare was not caching the responses even if the Cache-Control header was correctly set. The reason is that, by default, Cloudflare solely caches assets and not HTML pages.

A Cache Rule has to be created that makes all requests with the appropriate headers eligible for caching.

Cache Control incoming requests

Result: 95% of requests cached

Cloudflare cached 95% of all requests to the Holocafe website during the TV appearance and the 30 minutes of corresponding traffic spike. This includes assets such as images, JavaScript and CSS.

Cache Control Zusammenfassung Anfragen

During that period, the 9000 unique visitors only caused approximately 13000 requests hitting the Laravel application, with a maximum of 2000 requests per minute. An amount the PHP application server could handle easily.

Cache Control Besuche insgesamt

In fact, looking at Tideways there was no visible change in performance due to the request spike, everything sailed along smoothly.

Cache Control Response Times

In conclusion, the caching on the CDN / reverse proxy was a simple enough investment compared to making the PHP application handle more concurrent requests. Considering there were only a few days to prepare for the TV appearance, this was a good decision, as changing the code of an application not designed from the beginning for high traffic is usually very complicated.

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.

PHP 8.4 improves Closure Naming for simplified debugging

In applications that use closures excessively, understanding stack traces as part of the debugging experience has historically been complicated by the fact that the names of closures did not include the source location.

In a PHP stack trace, whenever a frame was represented by a closure, it only contained the reference {closure} and the namespace the closure was declared in, leading to all closures within a namespace looking identical.

Take this line of code, acting as a sort of wrapper trampoline for measuring execution time of a block of code in Shopware 6:

$response = Profiler::trace(
    'twig-rendering',
    fn () => $this->render($view, $event->getParameters(), new Response())
);

Before PHP 8.4, an exception thrown somewhere in “render” produced the following stack trace:

PHP Fatal error:  Uncaught RuntimeException in /root/index.php:32
Stack trace:
#0 /root/index.php(27): Shopware\Storefront\Controller\StorefrontController->render()
#1 /root/index.php(40): Shopware\Storefront\Controller\StorefrontController->Shopware\Storefront\Controller\{closure}()
#2 /root/index.php(26): Shopware\Core\Profiling\Profiler::trace()
#3 /root/index.php(18): Shopware\Storefront\Controller\StorefrontController->renderStorefront()
#4 /root/index.php(10): Shopware\Storefront\Controller\ErrorController->error()
#5 /root/index.php(7): Symfony\Component\HttpKernel\HttpKernel->handleRaw()
#6 /root/index.php(47): Symfony\Component\HttpKernel\HttpKernel->handle()
#7 {main}
  thrown in /root/index.php on line 32

When the code in StorefrontController::renderStorefront used multiple closures, this stack trace would not uniquely identify which closure was called.

With PHP 8.4, the closure name now contains the source location it was declared in:

PHP Fatal error:  Uncaught RuntimeException in /root/index.php:32
Stack trace:
#0 /root/index.php(27): Shopware\Storefront\Controller\StorefrontController->render()
#1 /root/index.php(40): Shopware\Storefront\Controller\StorefrontController->{closure:Shopware\Storefront\Controller\StorefrontController::renderStorefront():26}()
#2 /root/index.php(26): Shopware\Core\Profiling\Profiler::trace()
#3 /root/index.php(18): Shopware\Storefront\Controller\StorefrontController->renderStorefront()
#4 /root/index.php(10): Shopware\Storefront\Controller\ErrorController->error()
#5 /root/index.php(7): Symfony\Component\HttpKernel\HttpKernel->handleRaw()
#6 /root/index.php(47): Symfony\Component\HttpKernel\HttpKernel->handle()
#7 {main}
  thrown in /root/index.php on line 32

Additionally, var_dump of a closure now includes its location:

object(Closure)#3 (4) {
  ["name"]=>
  string(84) "{closure:Shopware\Storefront\Controller\StorefrontController::renderStorefront():26}"
  ["file"]=>
  string(15) "/root/index.php"
  ["line"]=>
  int(26)
  ["this"]=>
  object(Shopware\Storefront\Controller\ErrorController)#2 (0) {
  }
}

Tideways Profiler and Xdebug already had code like this, and my colleague Tim contributed it back to PHP core for inclusion in 8.4.

This change will improve Tideways and other APMs/Exception Tracking tools in places where they use PHP backtrace functionality. Just like in Tideways exception tracking here:

Closure naming simplified debugging

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.

Using Composer Patches to fix Performance in Third-Party PHP Libraries such as Symfony ErrorHandler

Every so often, a careful profiling analysis of your application detects the bottleneck in third-party code and there is little remedy for you to override or change that code. In this blog post I want to present a solution to fixing performance problems in third-party libraries by using the composer patches plugin.

I use the the Symfony ErrorHandler as an example. Another example could be patching a performance bottleneck in Doctrine DBAL array parameter processing for an older version with the code from a newer version.

For certain applications that frequently trigger Symfony deprecations, this may result in a significant loss of response time, because it treats deprecations as a “strong error” that should not be silenced. For instance, in this Tideways trace from a Shopware store, a total of 18794 deprecations result in an additional response time of 699 ms:

ErrorHandler

An obvious first step would be to fix the code that triggers the deprecations, but here the Shopware core is triggering this deprecation itself. Shopware core is third party code that we cannot just change, especially when installed via Composer. The original code is installed over our changes on every Composer installation or update.

An issue to request a change in this policy to treat deprecations as errors that can never be silenced has been open for a while now. As usual, it’s not as easy for the developers of a framework to find a generic solution that works for them and everyone. 

We can intervene here by using the excellent cweagans/composer-patches library, which allows patching vendor code as a subsequent step after installing or updating dependencies. Install the library into your project: composer require cweagans/composer-patches:2.0.0-beta2

Composer Patches 1

Then we create a new file “patches/symfony_error_handler.patch” and add the following patch for symfony/error-handler:

diff --git a/ErrorHandler.php b/ErrorHandler.php
index c0af370..6b12da8 100644
--- a/ErrorHandler.php
+++ b/ErrorHandler.php
@@ -395,7 +395,10 @@ class ErrorHandler
         $level = error_reporting();
         $silenced = 0 === ($level & $type);
         // Strong errors are not authorized to be silenced.
-        $level |= \E_RECOVERABLE_ERROR | \E_USER_ERROR | \E_DEPRECATED | \E_USER_DEPRECATED;
+        $level |= \E_RECOVERABLE_ERROR | \E_USER_ERROR;
+        if ($this->debug) {
+            $level |= \E_DEPRECATED | \E_USER_DEPRECATED;
+        }
         $log = $this->loggedErrors & $type;
         $throw = $this->thrownErrors & $type & $level;
         $type &= $level | $this->screamedErrors;

I would always store the patch locally and avoid using the remote patch feature of this plugin for security reasons.

And modify the composer.json to include a reference to this patch like this:


{ 
    "_": "...",
    "extra": {
        "symfony": { "_": "..." },
        "patches": {
            "symfony/error-handler": {
                "Remove deprecated from strong errors": "patches/symfony_error_handler.patch"
            }
        }
    }
}

After this change, you can apply the patch by calling “composer patches-repatch”:

Composer Patches 2

From this point forward, the patch will be applied during every composer installation or update if the error-handler package changes.

Please be aware that this change effectively disables Symfony deprecation functionality unless the Symfony application is in debug mode.

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.

Tideways 2024.3 Release

We strive to improve clarity and user-friendliness, and have thus focused our efforts on several features that align with this objective. Our new Sidebar Menu, the Release Tracking Feature and an increased comparison range for Releases and Markers as well as the possibility to show error messages in notifications all fall into this category.

Summary:

PHP 8.4 support from the start

PHP 8.4 will be released at the end of November 2024 and Tideways – as always – supports  it from the start with the PHP Extension Release 5.14.0.

The last versions of the PHP extension also added new instrumentations: DNS functions, password hashing and sending e-mails are now visible in the Timeline Profiler.

Turn navigation into an intuitive experience: Our new Sidebar Menu

We are convinced that the new Sidebar Menu improves usability and discoverability and makes the overall navigation experience more intuitive. With an increasing amount of primary features in Tideways, the top navigation no longer provided enough room to accommodate them anymore. A sidebar navigation proved to be the most effective approach to meet this challenge and enhance its user-friendliness.

There are new shortcuts in the sidebar navigation:

  • If you click on the Tideways logo or the organization, a popup will open, allowing you to swiftly navigate between all your organizations and projects.
  • From the footer navigation, you can access user and organization settings.
  • Main features such as Tracepoints, Incidents or Slow SQL queries are now available directly from the sidebar navigation.

In the line of improving our menu, it is now possible to jump directly to the relevant performance details of a trace via the widget “Go”.

We decided to switch to the new Sidebar Menu completely and activate it for everyone by default. You will be able to go back to the old sidebar navigation from the “User Settings” until the end of the year 2024; thereafter, only the new Sidebar Menu will be available.

Compare Aggregated Traces directly from Incident and Release Tracking

What was previously feasible at the monitoring level has now become feasible also at the profiling level.

Now, it is possible to identify the leading cause behind a change in performance, rather than simply observing that a change happened. 

Subsequently, it has become more convenient to compare performance data from two distinct time periods.

You can instruct Tideways to compute an aggregate of traces from before and after the comparison time and Tideways will immediately show you the differential flame graph that allows spotting the degraded calls such as SQL queries.

There are three possibilities to start into this performance comparison 

  1. Set/Add markers. To display performance before and after a selected date, you can set/add a named marker. This is useful to reference important events in the project lifecycle and allows a swift comparison between the before and after. A significant event could be, for instance, the start of an email marketing campaign.
  1. Use the Release Tracking Feature: Since markers do not support automated notifications, the Release Tracking Feature should be used to compare performance prior to and subsequent to releases. You can see a list of recent releases on the left side of the release tracking screen: 

Below the details, which show a before-and-after comparison, transactions for this timeframe are listed:

3. Response Time Incident: When a response time incident is triggered the Tideways UI now shows the most degraded and improved transactions in the timeframe before and after the start of the incident.

Increased Comparison Range for Releases and Markers

Comparing data sets from different time periods can be very revealing when you want to evaluate the performance of a website. By extending the time frame from a maximum of 90 minutes to a maximum of one day, a whole day’s worth of data can now be included in the comparison of release events or markers. Fluctuations can be factored out much better, and thus results are more meaningful. You get more insight into what has really changed.

Crawler Detection

Crawlers can be recognized in Tideways and are now displayed with the tag “crawler”. Using filters, only the traces that were triggered by crawlers can be shown.

The identification of crawlers works based on user agents. It is not enabled by default and can be enabled with php.ini configuration change: tideways.features.crawler_detection=1

Allow opt-in to show error messages in notifications

We’re pleased to announce that we’ve built this feature in response to quite a few customer requests and that it’s ready for use now. They asked us to make it easier for them to see exception messages directly in notifications, and we’re delighted to have done that for them.

Exception messages can be the key to determining the cause of an error easily. Since they could potentially contain private data, we initially decided against including them in notifications. 

However, as their importance can’t be underestimated, we now allow toggling to include error messages in email, Slack or Microsoft Teams notifications. The inclusion of error messages remains disabled by default due to privacy concerns.

Don’t get spooked by bad PHP performance. Try Tideways and get your remedy!

PHP 8.4 Property Hooks: Can we get rid of getters/setters now? A benchmark.

The upcoming PHP 8.4 release will include the brand-new feature “property hooks”, a mechanism to add logic to a class property when read from or written to. One benefit of this feature is that you do not need to protect property access with a private property and public getter/setter methods anymore.

You can find out how this feature works in the RFC and other places, but in the spirit of this blog we want to focus solely on the performance implications.

We would like to know if there is a noticeable effect on performance when we design applications from scratch with public properties backed by hooks and without getters/setters? 

Or, more drastically, is it a winning strategy to leave the getters/setters behind, never look back, maybe even rewrite our existing applications?

From a macro-perspective, the answer is no.

Looking at a call graph for our own Transaction settings list view, we see that even when rendering a large amount of 100 Doctrine entities, calls to their getters account for a minimal amount of time in the request, fractions of milliseconds.

Changing the code to use public properties and property hooks will not significantly alter the application performance.

Nevertheless, it’s interesting to dive into a benchmark of property hooks vs. getter/setters, to understand if both approaches have a similar performance. If they do, it is unnecessary to consider performance, when deciding between the two. And that reduces mental load.

Establishing Baseline: Public Property vs Getter/Setter

It’s a well-known fact that public property access is faster than going through a getter method to access a private property. We can set up a benchmark with hyperfine to calculate that difference to be roughly 60% slower for getters/setters with these code examples:

Public Properties are rarely used, despite their speed, due to the importance of other concerns:

  1. Information Hiding: Using getters/setters allows us to avoid exposing how the data is stored in the class.
  2. Changing a public property to a getter in the future requires numerous refactorings, or the use of magic __get methods, Both of these are unnecessary risks that can be avoided by using getter/setter methods from the start.

With property hooks, both concerns can be addressed by adding property hooks, when the access to the public property should be restricted in any way.

Comparing Public Property with Hook vs. Getter

So, let’s add a property hook example for reading and writing that resembles the logic of the getter and setter methods.

Both approaches are very close to each other with only a 9% performance difference.

So the result is clear: Performance should not be a concern when you decide to use either properties with hooks or getter/setter methods for accessing properties on classes. This is especially true because, as we saw at the beginning, property access usually accounts for only a small part of the total request time.

The case for benchmarking: Hook performance improvements during PHP 8.4 alpha

While it is important to know that both approaches have similar performance characteristics, micro-benchmarking new features vs. old alternatives serves another purpose. 

We wanted to write this blog post directly after property hooks were merged because from our experience we expected the conclusion to be that performance is not a concern.

But when we ran the numbers with an early PHP 8.4 alpha, we realized that property hooks were about 200% slower than getters. 

Turns out, the implementation of property hooks was not optimal, and we reported this behavior to the property hook feature authors Ilija and Larry. The fix required another amending RFC, but was approved by PHP RFC voters, leading us to the performance we have now during the PHP 8.4 release candidate phase.

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.

Choosing a PHP Library based on Performance

Sometimes, performance is the primary requirement when you are picking a third-party library to solve a task in your application. 

For CPU intensive work, there are often similar alternatives that you can choose from:

  • Serialization with Serde, JMS Serializer or others
  • Crawler Detection with jaybizzle/crawler-detect or matomo/device-detector
  • Dates and Times with nesbot/carbon or cakephp/chronos

To find out which one of them is more performant for your use-case, you can set up an experiment with microtime/hr_time calls and run them against each other.

But: this provides fewer insights than running your tests directly with a Profiler such as XHProf or Tideways!

Comparing Performance of PHP Crawler Detection Libraries

For the use-case of crawler detection inside Shopware, I recently wondered which library to use when trying to detect if the current request is made by a crawler.

Naturally, you would say, let’s use PHPs native get_browser function. But there are also specialized libraries such as jaybizzle/crawler-detect or matomo/device-detector

Consulting their respective documentations, I came up with the following code snippet to test their speed:



use Jaybizzle\CrawlerDetect\CrawlerDetect;
use DeviceDetector\Parser\Bot AS BotParser;

require_once 'vendor/autoload.php';

$crawler = ['jaybizzle' => 0, 'device-detector' => 0, 'get_browser' => 0];

$bots = file(__DIR__ . '/bots.txt');
$bots = array_slice($bots, 0, $argv[1] ?? count($bots));

$crawlerDetect = new CrawlerDetect;
$botParser = new BotParser();

foreach ($bots as $bot) {
    $bot = trim($bot);

    if($crawlerDetect->isCrawler($bot)) {
        $crawler['jaybizzle']++;
    }

    $botParser->setUserAgent($bot);
    $result = $botParser->parse();

    if (!is_null($result)) {
        $crawler['device-detector']++;
    }

    $result = get_browser($bot);
    if ($result->crawler) {
        $crawler['get_browser']++;
    }
}
var_dump($crawler);

I searched for different sources of example user-agents and compiled a list of roughly 2700 ones, then generated a Tideways profiling trace from the CLI for the snippet with:

tideways run php crawler.php

The result disqualifies PHPs internal get_browser because it is pretty slow. 

Both userland libraries are within 12% of each other, with 767ms vs. 685ms.

That gives me confidence that either one of them is quick enough to be used in a request testing just the one user agent of the current user.

To verify this, I generate another callgraph where each is testing the same single HTTP header and get surprised. Now matomo/device-detector is 10x slower than jaybizzle/crawler-detect.

The reason is that matomo/device-detector initializes a regular expression once, calling AbstractParser::getRegexes, which takes roughly 18ms. The actual test of the user agent against the initialized regex is quite fast. In comparison, jaybizzle/Crawler-Detect code-generates their regex as a build step.

As a result, I’d rather pick jaybizzle/crawler-detect for my use-case of detecting a crawler in a single request, as 2ms is acceptable but 20ms is already a bit too much.

This does not disqualify matomo/device-detector in general, it could primarily be used inside a background job when analyzing many user agents at the same time, so this library might not be optimized for my use-case.

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 bottlenecks, lazy loading times and errors! Without Tideways, you’re likely to fish in murky waters attempting to figure it out. Try our free trial today and be enlightened.

Dealing with MySQL Lock Timeouts: Bail faster

When using MySQL and InnoDB you will inevitably run into lock timeouts sometime, somewhere. 

We have recently started seeing this with some of our Shopware 6 customers in their storefronts or worker queues, so I was reminded to go back to 2017 in our codebase when we put a fix in place.

In our case, this happened on tables that were constantly written to from many different sources in the code base. But it only happened once every few hours or days in worker or cron jobs, and usually fixed itself by retrying the task, or even the function call itself at the code level.

In Tideways you’ll get a notification about the lock timeout as an exception, and you’ll see that it happens sporadically. In the case represented in the following screenshot this has happened a few times in the last days, always around 21:00:

One downside to MySQL lock timeouts is the default wait time: 50 seconds! This is a time where your web server, workers or cron jobs are idle. In our case when a backlog happened it quickly spiraled out of control and all workers suddenly had to wait.

Dealing with this timeout can make the difference from your application failing from the backpressure of requests or tasks that do not get processed fast enough and ties into decreasing timeouts across the board, a topic that we have blogged and podcasted about before.

If your code reaches the wait timeout failure and stops execution completely, a 1-second wait should be fine. Our write heavy worker tasks therefore change the timeout at the beginning of each task:

$this->connection->exec('SET innodb_lock_wait_timeout=1');

Or, you can change this setting directly in the MySQL server so that it doesn’t have to be a concern of your application.

If the occasional lock timeouts are acceptable and your application can recover from them, then this is a much simpler solution than re-architecturing your data write access to avoid the locks altogether.

What an AI would never be able to tell you the way we can! Follow us on LinkedIn or X and subscribe to our newsletter to take a deep dive into technical issues surrounding PHP performance.

You’ll never know until you try … our free trial!

New in PHP 8.4: engine optimization of sprintf() to string interpolation

PHPs compiler and bytecode cache OPcache not only cache the compile step from PHP source code to virtual machine bytecode, they also include optimizations that can produce faster bytecode:

For example PHP can:

  • optimize empty functions away. You can see this in a Callgraph Profiler when the function is not appearing in the result. 
  • already compute the result of constant compile time expressions into their result, for example 1 + 1 as 2.
  • replace calls to often used functions with logic directly in the VM to avoid the function call overhead of the engine. We wrote about these compiler optimized functions on this blog before.

For PHP 8.4 Tideways sponsored the work of our colleague Tim to add another compiler optimization for the function sprintf().

If you call the function with a format string containing only %s placeholders the engine will compile the call to sprintf() directly into the equivalent string interpolation, removing the overhead of the function call and avoiding repeatedly parsing the format string.

Taking an example from our code-base, we build many of our Redis cache keys using sprintf() and a pattern like this:

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

With this optimization, the engine will automatically rewrite it to look like this code:

private function key(string $type, int $identifier): string
{
  return "last_ts_{$type}_{$identifier}";
}

This optimization will happen automatically during compilation of any PHP script and requires no work on your end.

We quickly followed up on this initial optimization with another pull request to add support for the %d placeholder.

Support for just these two placeholders allows the optimization to apply to more than 90% of the format strings encountered in the landing page of PHP’s Symfony Benchmark.

Given the widespread use of sprintf() in common PHP code (616 times in Tideways own backend code) this is an optimization that will make a small but significant contribution for the overall PHP performance.

With this optimization in mind, it also enables you to write or rewrite string manipulation code that is already performance sensitive to use sprintf() for better readability without making it slower.

What an AI would never be able to tell you the way we can! Follow us on LinkedIn or X and subscribe to our newsletter to take a deep dive into technical issues surrounding PHP performance.

You’ll never know until you try … our free trial!

Optimizing Shopware 6 Checkout Performance with Callgraph Tracepoints

A few weeks ago, Andrey from Ecommlab contacted me to ask me about a strategy to get performance insights from Tideways into Shopware 6 checkout. 

The store of our joint customer “Bär Schuhe” was slow for customers during the order confirmation process.

Optimizing checkout performance is important but much more difficult than product or category pages for two reasons:

  • Customers do not visit them nearly as often as they view products/categories.
  • As a developer it is difficult to put them into production without causing payment transactions and order cancellations in the ERP system. 

In this blog post, we will go through an optimization of the order confirmation step in the checkout process, reducing the 95% percentile performance from 1-2 minutes to 6-9 seconds.

Identifying the Bottleneck with a Callgraph Tracepoint

Looking at Timeline profiling traces collected from customers of the “Bär Schuhe” store, nothing immediately jumped out as a bottleneck.

To get more detailed profiling data at the function level, we created a callgraph tracepoint and, after waiting for the results, examined the slowest collected callgraph to identify the bottleneck. 

One plugin stood out, taking 66% of a 45-second request and causing approximately 50,000 objects to be created from database rows. This is a custom plugin that connects the store to the ERP system. The combination of slowness and excessive database load warrants an investigation of the plugin’s code:

There are a lot of associations being eagerly loaded for the order transaction, which makes us wonder which one is responsible for the majority of the 50,000 or so database objects being created. 

Comparing this piece of plugin code with other places in the Shopware core and available plugins, we determined that the bottleneck is most likely caused by eagerly loading the state machine history.

Confirming the Optimization Works

Andrey confirmed that removing this line did not break the code and rolled it out to production, where we confirmed our hunch: Performance for the order completion step of the checkout dropped from up to 2 minutes to a maximum of 17 seconds. 

The following comparison shows that the plugin code is now 29 seconds faster overall, and that the creation of 50,000 database objects was reduced, accounting for the majority of the time savings, 16 seconds.

Conclusion

This solves the biggest performance bottleneck in the order confirmation process code with a one-line change. 

Further improvements to the checkout were made to bring the user experience to an acceptable level, but each had a much smaller impact overall than this one.

This shows how based on insights from a Profiler, you can start optimizing the biggest bottlenecks first and minimize the time spent optimizing.

For a quick read, you also find this Case Study as a pdf here:

What an AI would never be able to tell you the way we can! Follow us on LinkedIn or X and subscribe to our newsletter to take a deep dive into technical issues surrounding PHP performance.

You’ll never know until you try … our free trial!

Measuring the DOM Namespace Reconciliation Performance Fix

This is the story of the manufacturing of seven-league boots for a function that is responsible for processing XML/HTML data in the PHP library. Optimizations in the PHP standard library, like here in ext/dom, have the potential to speed up the performance of applications significantly by an upgrade to the current PHP version. I don’t want to spill the beans right at the beginning but the performance optimization from PHP 8.2 to PHP 8.3 in percent is … no, I’ll show you later to keep up the suspense.

Let’s start at the beginning and work our way through the story of this particular bugfix, which began in 2019 when I was working on the RFC “DOM Living Standard“. I stumbled upon this performance problem which was brought to my attention by a Wikimedia programmer and thereupon created a bug report with “reproduce case“. This resulted in Niels Dossche authoring a performance fix.

What this performance improvement means can be seen in a Profiler such as Tideways. Using the new #[WithSpan] feature for the Timeline of the Tideways Profiler it is possible to see the duration of calls to certain functions of your codebase. I modified the code for reproduce case to include the attribute like this:

You can see in the Profiler that the “addParagraphs” function becomes slower with each call even though it does exactly the same and should accordingly be about as fast every time. 

Furthermore, by looking at the  “Callgraph”, it becomes possible to exactly pinpoint the place where the application is slow: The DOMNode::appendChild function taking 91% of the time.

PHP 8.2 Callgraph

PHP 8.3 compared to PHP 8.2 makes the performance improvement visible:

It sounds like magic was involved but the 94% performance increase was achieved by an optimization in the PHP standard library (in this particular example in ext/dom). The potential to make many applications faster by upgrading to the latest PHP version or at least a more recent version is sometimes underestimated.

Tideways 2024.1 Release

You can now unpack the Christmas present that was promised to you at the end of 2023! As you may have already read in the Flamegraph Feature Preview, this exciting new feature of the Profiler is now the focus of our first release in 2024.

The Flamegraph complements the Timeline and Callgraph features and visualizes Aggregated Traces. The goal was to provide profiling information across many traces and thereby gaining new and deeper insights into performance. The aggregated spans the Flamegraph makes visible in its easy-for-the-eye color scheme are very useful to get a representative average over many samples.

Summary:

Aggregated Traces

Tracepoints can now paint a representative average picture for all its traces. This gives a statistically more correct view of the trace by smoothing the effects of outliers.

Tideways computes an aggregated summary trace by averaging the data from all collected traces. The aggregated traces are visualized with a Flamegraph and thereby make an in-depth performance analysis possible.

When callgraph data is available for the tracepoint, these are also averaged in the aggregated trace.

This feature is only available to customers with current Tideways plans.

More information:

Feature Preview: Flamegraphs

The new Flamegraph Feature gives you a different visualization of existing data than you currently have with the Timeline spans and as such is a useful tool to measure time and memory consumption. It’s a well-known visualization, as it is used in many other performance tools. Tideways uses the inverted “icicle graph” which helps to avoid scrolling when starting at the top. For complex Timelines, Flamegraphs are usually more compact, since they aggregate lots of small spans of the same category into one frame.

We are planning to make improvements to the PHP Extension to supply more and more detailed data before we mark this feature as complete. For now it’s marked as a feature preview in the UI.

More information:

Missing Data Alerts

This feature proactively notifies you when there is no data flow, it identifies gaps or interruptions in data collection, thereby ensuring the reliability of data-driven processes.

For new projects created since early March it is activated by default for a timespan of 24 hours without discernible data flow. 

For existing projects the alert has to be created under Project Settings > Notifications for now. We plan to automatically create it for all existing projects in the next few weeks.

You can create and change that under “Notifications” and choose between different time periods of 1 to 24 hours, according to your specific business requirements. It helps you to quickly identify issues to minimize disruptions and improve operational resilience.

More information:

Ignore Control-Flow Framework Exceptions

Tideways now ignores a preselected list of framework exceptions by default that were used for control-flow and not to signal an actual error. 

This solves the problem that exceptions such as Symfony’s NotFoundHttpException or Shopware’s CustomerNotFoundException would create many entries in the exception tracking, including notifications, and a Tideways user would react to them by ignoring them, being distracted, or finding the button to ignore them.

Now we turn this around, these exceptions are ignored by default but if you need to see them you can configure that in the Project Settings => Exception Tracking => Ignored Exceptions menu.

Feature Preview: Sidebar Menu

We also implemented the Sidebar Menu which is aimed at improving usability, discoverability and making our overall navigation experience more intuitive. What’s more, the Sidebar Menu leaves more room for the profiling information. 

At present, both menus are available and the Sidebar Menu can be tested but due to the various advantages we are considering switching to the Sidebar Menu completely. Every user can take a look at the new layout by switching to it in User Settings: There you’ll find a button “Enable Sidebar Menu” under “Feature Preview: Sidebar Menu”.

PHP Extension 5.7 Release

To derive the most benefit from the new features it is necessary to install the latest version of the PHP Extension. We improved the quality of the PHP Extension 5.7 so that all our new features can be used to their fullest to support you in the best possible way.

New Shopware 6 Instrumentations

Tideways now has improved Shopware 6 instrumentation. Important functions of the Shopware API on product detail, category and other core pages are automatically instrumented with spans in the timeline to provide better context.

In addition, we have made Tideways compatible with the upcoming Shopware 6.6 release.

RdKafka Instrumentation

Tideways 5.7 includes instrumentation for Kafka producers using the rdkafka PHP extension. You’ll now start to see various Kafka-related spans within your timeline.

\Tideways\Profiler::markAsCliTransaction()

Tideways already automatically separates transactions using PHP’s CLI interface into the separate :cli service to prevent long-running cleanup tasks from skewing the number of web requests, making the performance profile incorrect for both the regular requests and the cleanup tasks.

As the CLI interface might not be available with all hosting providers, it forces them to run such cleanup tasks by means of a regular HTTP request. This will most likely intermingle them with the regular web requests by default.

Calling the new \Tideways\Profiler::markAsCliTransaction() method will cause the current request to be sorted into the corresponding :cli service, just as if it was executed using the CLI interface. Shopware 5’s web-based cronjobs are automatically detected to make use of this new functionality.

MongoDB Experimental

We improved the instrumentation support for MongoDB. You can now see the exact query that was sent. Query parameters are eliminated so that we can guarantee no personal information is transmitted to Tideways.

tideways.enable_cli now defaults to 1

The INI setting tideways.enable_cli now defaults to 1 instead of 0. Nevertheless, you can disable it and change it back. We are changing this default, because many users do this change eventually themselves already, often in response to struggling why no traces are collected on the CLI.

tideways.dynamic_tracepoints.enable_* now default to 1

Another improvement concerns the INI settings tideways.dynamic_tracepoints.enable_web and tideways.dynamic_tracepoints.enable_cli which now also default to 1 and will be activated with the next update. We are changing this default, because the tracepoints feature has been available for several years, many edge case problems have been addressed and we see no risk in activating it by default.

macOS Builds

Just to remind you that Tideways can be installed on macOS M1/M2/M3 (ARM) and Intel systems with the help of Homebrew, including the installation of PHP Extension, Daemon and CLI. We outlined for you how to get started with Tideways on macOS with Homebrew.

Docker Image for tideways-daemon

There is now a public docker image to use for the tideways-daemon where we previously only provided a Dockerfile in the documentation. The image is available under ghcr.io/tideways/daemon and we have updated the documentation to show it used with Docker and docker-compose.

Documentation: Install with Docker

Tideways 2023.2 Release

Since our last release announcement in April we have been working on a number of new features for Tideways that we are now happy to share with you. This is the second and final release for 2023.

From here the team will be looking ahead to 2024, the 10th anniversary of Tideways’ launch, and preparing some amazing new features and improvements.

Summary:

  • PHP 8.3 Support
  • #[WithSpan] Attribute
  • Redis Instrumentation Improvements
  • Automatic Service Detection for Shopware 5, 6 and Magento 2
  • Report PHP Notices & Warnings
  • Related Errors for Failure Rate Incidents
  • Support for Previous Exceptions
  • Transaction Failure Rates Beta
  • Trends of SQL, HTTP and other layers in History
  • Slack App

PHP 8.3 Support

PHP 8.3 will be released next week, on November 23rd 2023 and Tideways supports it from the start with PHP Extension release 5.6.4.

#[WithSpan] Attribute for Custom Instrumentation

Starting with Tideways PHP Extension 5.6 the attribute #[WithSpan] from the Tideways\Profiler namespace is available to automatically create a custom span for the Timeline Profiler during Profiling.

Redis Key Instrumentation 

When setting the new INI option „tideways.features.redis_keys=1“ in your php.ini, Tideways will now keep the key for every Redis operation and show them in the Timeline Profiler.

Automatic Service Detection for Shopware 5, 6 and Magento 2

Previously Tideways already had automatic service detection for Shopware 6, and with the extension 5.6 release this is extended to Shopware 5 and Magento 2.

With the automatic service detection, transactions for backend/API of these three e-commerce systems are assigned to a second service „api“ or „backend“, depending on the system.

The automatic service detection has lowest priority and you can always overwrite it by setting tideways.service ini, TIDEWAYS_SERVICE environment variable, call \Tideways\Profiler:.setServiceName at runtime or by simply disabling automatic service detection with „tideways.features.automatic_service_detection=0“.

Reporting of Notices and Warnings

Tideways now supports reporting of notices and warnings as part of the Error Tracking, completing support for the various types of errors that a PHP script can produce. 

Support for reporting notices and warnings is disabled by default and needs to be activated by setting tideways.features.warnings=1 and/or tideways.features.notices=1.

Support for Previous Exceptions

The Tideways PHP Extension now reports all previous exceptions in addition to the exception that crashed a request. The stacktrace of the previous exceptions and their messages are shown in the UI now.

Transaction Failure Rate Alerting (Beta)

In addition to service-level failure rate alerting, Tideways now supports failure rate alerting on the transaction level. This has many different use-cases, for example:

  • strict zero-error cronjob monitoring
  • better identification of failing project features that would not raise the service-wide failure rate. 
  • individual alerting in combination with response time targets – a feature released earlier this year.

In addition related errors that could be the cause of the increased failure rate are displayed for these incidents. Service-wide failure rate notifications also include Related Errors if available.

This feature can be activated from the Beta Features organization settings before it can be used. 

Trends of SQL, HTTP and other layers in History

The weekly report already included a report of the downstream layer performance of each service and a comparison to the previous week. This information is now also shown in the Trends section of the history report and allows a comparison on the day, week or granularity level across the complete retention of history data for a project.

Slack App for Tideways

You can now integrate notifications from Tideways with a Slack app – in addition to the previously available support using the legacy “Incoming Webhook” integration. This provides admins of the Slack workspace more control when integrating with Tideways.

The PHP stat cache explained

90% of the time when I explain how the stat cache works in PHP, people are surprised because they expected it to work differently. It was invented to solve a very limited problem when you call several file system related operations on the same file in quick succession.

Why should you know how it works? Because sometimes you need to work around the cache with the clearstatcache() function to get PHP code to run without errors.

Historical Context

Let us start at the beginning. What is stat? It is a C-level API call to the filesystem to query the metadata of a file, such as user, group, readability, writability, size, modification times. And PHP wraps this call with its own function that is also called stat and also uses this function internally in different places.

Historically, this has been an expensive operation to perform.

A number of PHP functions need this, PHP’s stat itself of course, but also file_exists, is_readable, is_writable and many more.

PHP’s stat cache

That is where the PHP stat cache comes into play: It caches the result of low-level stat operations. With one important caveat. The cache only caches this information for exactly one file. The last one that used the cache. And it caches this on the request-level, it is not a cross-request or process cache like OPcache.

This means that if PHP performs a low-level stat operation on a file and it wasn’t in the stat cache before, it will overwrite the previously stored stat data for the previous file.

How is the stat cache implemented?

If the stat cache were to be implemented in PHP, it would look something like this:

<?php
class FileStat
{
    private static string $CurrentStatFile;
    private static PhpStreamStatBuffer $ssd;

    public function stat(string $path)
    {
        if ($path === self::$CurrentStatFile) {
            return self::$ssd;
        }

        $statBuffer = new PhpStreamStatBuffer(stat($path));
        self::$CurrentStatFile = $path;
        self::$ssd = $statBuffer;

        return $statBuffer;
    }
}

Conclusion

So what does this tell us about the stat cache? Is it useful? It is hard to tell, especially on modern Linux and with SSD disks. But I didn’t measure, a topic for future research.

But if you want to make use of it, remember that you cannot interleave operations on multiple files, they would cause the cache to be overwritten.

You’ll find the complete list of functions that use the stat-cache in the documentation of the clearstatcache() function.

PHP Performance in 2022: A Year in Review

Now that 2022 is over, its time to review newsworthy topics about performance in the PHP language.

As this is the first time we are doing this type of blog post, please let us know your thoughts about this format and if we forgot important topics.

PHP 8.2 release 

With each new release of PHP, there are performance improvements to the engine or individual parts of the language. A list of performance related changes in the PHP 8.2.0 release from this year includes the following topics, discussed in more detail below:

  • Packed Arrays (List Arrays) are stored in a more efficient data structure, leading to reduced memory footprint and better performance.
  • stripos() performance improved significantly
  • Performance of different functions in mbstring extension improved
  • new memory_reset_peak_usage() function introduced

Tideways also already supports PHP 8.2 and a handful of customers are already running it in production.

More efficient packed arrays (lists)

Arrays in PHP are lists, hashmaps or both at the same time. For the “array as list with items indexed by 0” use case, PHP 8.2 ships with a more memory-efficient representation, contributed by Dmitry Stogov in #7491

This change significantly reduces the memory overhead of the array structure.

A test script that creates an array list of integers with 100 items reduces memory usage from 4,3 MB to 2,3 MB (3v4l.org):

<?php

function build_list() {
    $list = [];

    for ($i = 0; $i < 100000; $i++) {
        $list[] = $i;
    }
}

var_dump(memory_get_peak_usage() / 1024 / 1024);

stripos() performance improved

Before PHP 8.2, stripos would lowercase the string and then use strips internally, which has suboptimal performance semantics for a few edge cases.

In PHP 8.2 Ilija Tovilo added a custom stripos implementation in PR #7852 that exhibits better performance characteristics for all use-cases.

Performance of different functions in mbstring extension improved

Alex Dowad is consistently working on improving the mbstring extension and PHP 8.2 includes a large range of PRs that increase the performance of different functions in mbstring for different character sets.

New memory_reset_peak_usage() function introduced

With the new function memory_reset_peak_usage() contributed to PHP 8.2 by Patrick Allaert in #8151, the value for the memory_get_peak_usage() resets to the currently used memory. This function is helpful for PHP scripts that perform multiple tasks, such as daemons, workers and cronjobs, to accurately measure each task’s peak memory impact.

Franken PHP

A new experimental web server for PHP was released called FrankenPHP. It is written in Go and uses the Embed SAPI to run PHP requests as threads within the Go web server.  

The interaction between Go and PHP is possible with a recent 8.2 version and higher, It also requires  PHP to be compiled with ZTS (Zend Thread Safety). 

FrankenPHP is an interesting project to watch because it is the first time a „SAPI“ directly includes a powerful web server (based on Caddy). This could simplify deployment of PHP applications compared to running both Nginx/Apache and PHP-FPM.

FrankenPHP is also the only PHP SAPI that provides HTTP 103 Early Hint support to increase performance for certain sites.

Revolt Event Loop released stable version

With PHP 8.1 Fibers were introduced to allow cooperative multi-tasking. Now in 2022, the first projects are now building on Fibers to provide new frameworks and features to PHP users.

The revolt/event-loop  is a Composer package that provides the basic building blocks for a Fiber-based event loop. It released the first stable version 1.0 in November 2022.

React-PHP and amphp are currently integrating Revolt in their event loop implementations.

Did we miss anything important? Let us know [email protected] or @tideways on Twitter.

Photo by Jason Leung on Unsplash

A story of Lazy Loading File System Operations for better dev system performance

In this blog post I want to share a story of a performance bottleneck using the filesystem that we experienced in our development setup.

In the Tideways backend, we have a simple homegrown database migration tool that scans a directory for .sql files and applies them if not already done. It is a very old piece of code that I used since before the times of doctrine/migrations. It is much simpler but works for us.

To avoid the development system running with an outdated schema after git pulling commits with SQL schema changes, this database migration tool checks for missing migrations to apply on every single request (in the development environment).

This check only requires 1 SQL select to fetch the already applied changes from the changelog table and one glob to fetch the list of all schema files. Should be quick and straightforward! Right? The high-level code looks something like this:

Looking at the callgraph however, shows a bottleneck in file_get_contents:

We have 450 SQL migrations already, and for checking the current status the content of each SQL file is loaded into memory in DbDeploy::getAllMigrations().

The MigrationStatus::hasOutstandingMigrations() method shown above does not need the SQL file contents, so we can refactor the code to call file_get_contents at the last possible moment.

This lazy loading pattern is widespread when it comes to database operations but also makes sense for the file operations here. The virtual filesystem of our development environment, which makes file operations considerably slower than with a non-virtual filesystem, adds extra time to these operations.

After refactoring away the file_get_contents calls from the DbDeploy::getAllMigrations() method into Migration::getSQL() that is only called directly before executing the changes, we see improvements.

A 90ms improvement by comparing a callgraph before and after the change:

  • File I/O drops from 128ms to 49ms
  • Overall response drops from 246ms to 154ms
  • file_get_contents duration improves by 90ms

A nice gain, considering it runs on every request in the development environment!