Posts

Fine-Tune Your OPcache Configuration to Avoid Caching Surprises

Learn more about one of the most important levers for optimal PHP performance. OPcache provides support for byte-code caching of PHP scripts. For a dynamic language such as PHP, a byte-code cache can increase the performance significantly because it guarantees a script is compiled only once. OPcache has been bundled with PHP as a loadable extension since PHP 5.5. Starting with PHP 8.5 OPcache is an integral part of PHP and always installed, but not necessarily always enabled.

Normally PHP executes scripts in two steps:

  1. PHP source code is compiled into bytecode
  2. The PHP virtual machine executes that bytecode

OPcache stores compiled bytecode in shared memory so that subsequent requests can skip the compilation step entirely.

The default settings of the OPcache extension already boost PHP performance by a large amount, but you can tune the configuration settings to get even more out of it.

The Three Most Important OPcache Configuration Options

In practice, most performance improvements from OPcache come from configuring three settings correctly:

  • opcache.memory_consumption
  • opcache.interned_strings_buffer
  • opcache.max_accelerated_files

If these are configured properly, you usually achieve around 95% of the possible OPcache performance gains.

A word of caution: This post repeatedly mentions monitoring stats with opcache_get_status(false). Because OPcache uses a unique pool of shared memory for every SAPI, you cannot access the webserver’s statistics from the console. The call has to be made through Apache or PHP-FPM.

Avoid Destructive Caching Behavior When Memory is Too Small

OPcache uses 128 MB of RAM to save the compiled PHP scripts by default and up to 16,229 PHP scripts. This sounds more than enough to store your PHP application scripts, but there are caveats:

  • If your application uses code generation or a PHP file-based cache such as Symfony, Magento or Shopware, then there might be a large amount of scripts that are not part of your source control.
  • If you do validate timestamps and your code changes in production, then the old cache entries are marked as “waste”. This waste adds to the memory consumption of OPcache.
  • If you use the “new checkout for every release” deployment strategy, then OPcache might cache the same script multiple times in different filesystem locations. This can crowd out the available memory fast.

When OPcache is “full”, under some circumstances it will wipe all cache entries and start over from an empty cache. When this happens and your server gets large amounts of traffic, this can cause a thundering herd problem or cache slam: lots of requests simultaneously generating the same cache entries.

You absolutely want to avoid OPcache to restart with an empty cache.

The algorithm that detects whether OPcache is “full” depends on an interaction between three different INI settings, which is not intuitive and currently not documented. It helped to dig through the C code to understand it.

The three relevant INI variables are defined as follows:

  • opcache.memory_consumption defaults to 128 MB for caching all compiled scripts.
  • opcache.max_accelerated_files defaults to 10,000 cacheable files, but there is a calculation in place that always picks the next specific higher number in a list of possible values. While this is documented, it is counterintuitive. The maximum is 1,000,000.
  • opcache.max_wasted_percentage is the percentage of wasted space in OPcache that is necessary to trigger a restart (default 5).

OPcache works on a first-come, first-serve basis for the cache and does not use an eviction strategy such as Least Recently Used (LRU).

Whenever maximum memory consumption or maximum accelerated files is reached, OPcache attempts to restart the cache. However, if the wasted memory inside OPcache does not exceed the max_wasted_percentage, OPcache will not restart, and every uncached script will be recompiled every request as if there was no OPcache extension available.

This means you absolutely want to avoid OPcache being full.

To find the right configuration, you should monitor the output of opcache_get_status(false) which you can use to check for the memory, waste and number of used cache keys:

  • If cache_full is true and a restart is neither pending nor in progress, that probably means that the waste is not high enough to exceed the max waste percentage. You can check by comparing current_wasted_percentage with the INI variable opcache.max_wasted_percentage. In this case also the cache hit rate opcache_hit_rate will drop below 99%, where ideally it gets as closely as possible to 100%. Solution: Increase the opcache.memory_consumption setting.
  • If cache_full is true and num_cached_keys equals max_cached_keys then you have too many files. When there is not enough waste, no restart will be triggered. As a result, there are scripts that don’t get cached, even though there might be memory available. Solution: Increase the opcache.max_accelerated_files setting.
  • If your cache is never full, but you are still seeing a lot of restarts, that can happen when you have too much waste or configured the max waste percentage too low. Solution: Increase the opcache.max_waste_percentage setting.

To look for inefficient restart behavior, you can evaluate the oom_restarts (related to the opcache.memory_consumption setting) and hash_restarts (related to opcache.max_accelerated_files). Make sure to check when the last restart happened with last_restart_time statistic.

To find a good value for opcache.max_accelerated_files you can use this Bash one-liner to get the number of PHP files in your project:

$ find project/ -type f -iname "*.php" | wc -l
    45311

Make sure to account for code-generated, Composer vendor and cache files or multiple applications running inside the same FPM worker pool.

Increase Interned Strings Buffer

When parsing PHP files that contain hardcoded strings, OPcache converts them to interned strings in memory. Meaning that a string "foo" in the code used at different locations and executed in different requests at the same time all point to a single place in memory. This reduces the memory overhead of PHP massively. However, the interned strings buffer is only 8 MB large by default. For applications using frameworks, it makes sense to increase this significantly to 32 MB or 64 MB:

opcache.interned_strings_buffer=64

Interned strings include many elements of PHP code, such as class names, function names, constants, or string literals. Reusing these across requests can significantly reduce memory allocations during execution.

Note that the interned strings buffer is allocated from the total OPcache memory pool. If you increase this value significantly, you also need to increaseopcache.memory_consumption. These two variables are linked.

You can find information on this in the Tideways app and get recommendations on if you should change variables or if you are fine as it is.

Avoid Unnecessary Filesystem Calls by Disabling Timestamp Validation

With the default settings, whenever a PHP file is executed (for example, through include or require) OPcache checks the last time it was modified on disk. Then it compares this time with the last time it cached the compilation of that script. When the file was modified after being cached, the compile cache for the script will be regenerated.

This validation is not necessary in production when you know the files never change. To disable timestamp validation, add the following line to your php.ini:

opcache.validate_timestamps=0

Disabling timestamp validation also avoids filesystem stat calls during script execution. In practice this can improve performance by roughly 1–3%, depending on the type of application.

With this configuration, you have to make sure that during a deployment, all the caches get invalidated. There are several ways to ensure this happens:

  • Restart PHP FPM or Apache – The sledgehammer method of invalidating files in OPcache has the downside that it might lead to aborted requests and a very small amount of time when requests get lost.
  • Calling opcache_reset(), which is tricky because it has to be called in a script executed by Apache or PHP-FPM to affect the webserver’s OPcache. You can add a special endpoint to your application and secure it using a secret hash.
  • Changing the document root and reloading the Nginx webserver configuration. Nginx completes all the currently running requests and starts new workers in parallel that serve requests with the new configuration.
  • For Apache, Rasmus has a blog post detailing their approach at Etsy.

Be careful with the last two approaches because using them results in making the available OPcache memory “full” very fast. See the previous section for more information on how memory configuration works.

For both cases you might need a maintenance script that calls opcache_invalidate($file, true) on all scripts in old deployments or opcache_reset() directly. Otherwise, old deployments crowd out the cache for new ones, and you might end up with a full cache and an unaccelerated application.

Advanced OPcache Optimizations

OPcache Preloading

We have a separate article on OPcache Preloading. There is also a video on the topic on our YouTube channel.

OPcache on the Commandline

By default, OPcache is disabled for CLI scripts, the since the INI setting is opcache.enable_cli=0 by default.

This is intentional because each CLI invocation runs in its own process and cannot share cached bytecode. In fact each of them need to allocate the memory for OPcache over and over again.

Enabling OPcache can be beneficial for long-running CLI workers, as enabling OPcache will also enable the bytecode Optimizer that can make the the script run faster.

However, this requires additional memory, and if you’re running a lot of workers or cron jobs in parallel, you get closer to the maximum memory of the server.

Therefore, often it does not make sense to enable OPcache for the CLI.

OPcache File Cache

OPcache can also store compiled bytecode on disk using the file cache feature.

This can help reduce cold start times in environments where PHP processes start frequently. This makes sense for use-cases like PHP on AWS Lambda using Bref.

You can enable the file cache with the following INI setting:

opcache.file_cache=/path/to/cache/

Now, PHP 8.5 introduced the setting:

opcache.file_cache_read_only=1

which allows using the OPcache file cache on read-only file systems, making it easier to use in container images.

Configuring OPcache in FPM Pool file does not work

A common configuration mistake occurs when OPcache memory settings are configured inside PHP-FPM pool configuration:

php_admin_value[opcache.memory_consumption]=256

This does not work as expected.

OPcache memory is allocated when PHP starts, before PHP-FPM pool configuration is applied.

As a result, phpinfo() may show the changed value while the actual allocated memory still uses the default.

Always configure OPcache memory settings in:

php.ini

Starting with PHP 8.5, PHP will emit a warning in such situations, making this issue easier to detect.

Joining The Threads Together

Everything together gives you the following php.ini settings file:

opcache.memory_consumption=256
opcache.interned_strings_buffer=64
opcache.max_accelerated_files=100000
opcache.validate_timestamps=0

; for cases with cold start or containers
opcache.file_cache=/path/to/cache/

Conclusion

Even though OPcache already provides a significant boost in PHP performance, you can still improve on it. This blog post expands on the information available in the PHP documentation on opcache_get_status and the opcache configuration.

By tuning the most important configuration settings and monitoring OPcache statistics, you can ensure that scripts are consistently served from cache and avoid unexpected performance issues.

Tideways 2026.1 Release

We’re rolling out a new wave of improvements across Tideways in our first Release of 2026, focusing on deeper visibility, smarter automation, and broader ecosystem support.

From automatic tracepoints for selected transactions and improved exception workflows to enhanced FrankenPHP worker-mode instrumentation, these features continue to reduce manual effort while increasing observability.

We’ve also expanded tracing capabilities: distributed tracing now activates automatically alongside tracepoints, and session performance is measured in a dedicated layer to help uncover locking bottlenecks. Shopware users benefit from new invalidate-cache tagging for better HTTP cache debugging, while Redis users gain key-level visibility with improved phpredis instrumentation.

Framework support continues to grow with automatic instrumentation for Tempest and Yii3, alongside improved deployment support for (Open)LiteSpeed environments. On the performance side, Tideways can now flag costly Symfony polyfill usage and recommend native extensions where beneficial.

Finally, observation data is now accessible via the REST API, making it easier to integrate detected bottlenecks into your own workflows.

As always, these updates aim to help you spot performance issues faster and spend less time on manual setup.

Automatic Tracepoints for Selected Transactions

You can now create automatic tracepoint triggers for specific selected transactions, provided that you have a Business or Enterprise plan with advanced tracepoint triggers.

Select the “Tracepoint Trigger” option from the settings dropdown on either the transaction details page or the transaction settings list.

Then configure the schedule to be daily, weekly, or monthly and the duration of the tracepoint to get started.

Acknowledge Errors and Exceptions

It’s now possible to acknowledge errors/exceptions from the error details screen, and if you don’t acknowledge an error, it will send another notification every 24 hours until it is either acknowledged, ignored, or resolved.

This helps with the situation where a notification for a new exception is overlooked accidentally and never seen again.

For all projects created before February 2026, the notification on unacknowledged errors is disabled by default. You can enable it in the settings for the “New Exception” notification.

Worker-Mode for FrankenPHP

Running FrankenPHP in worker mode now automatically recognizes every request run in the main loop, calling frankenphp_handle_request. There are no code changes required in your application to get the same support as with PHP-FPM or Apache mod_php.

This is based on the automated worker mode instrumentation, previously introduced for job queues.

Previously, implementing the necessary code changes was particularly difficult when the framework itself provided the worker script, in the case of Symfony and its Runtime component.

Tracepoints and Distributed Tracing

If distributed tracing is enabled in the PHP extension, it is now automatically activated for requests with an active tracepoint

Previously, distributed tracing was only activated for developer-triggered requests via the Chrome Extension or CLI.

Documentation: Distributed Tracing

Session Layer

Tideways now measures the time spent on session-related operations in a dedicated session layer. This primarily supports detecting blocking session performance issues due to locking, as previously described in our blog.

This includes both loading the session and updating session data when the request completes.

Supported frameworks include PHP’s session extension (including Symfony), Laravel, and WoltLab Suite.

Invalidate-Cache-Tag for Shopware

To improve our HTTP Cache debugging support from the 2025.3 release, we now add an “invalidate-cache” tag to all traces that trigger invalidation of any item in the Shopware HTTP cache. This works for both use cases of direct or deferred invalidation.

This feature should help you detect requests that are invalidating the cache unexpectedly and which may contribute to a bad page cache hit rate.

The detailed trace view then shows which cache item keys were invalidated with the timeline “markers”.

phpredis Key Instrumentation

In addition to Predis and Credis support, instrumentation for the ext/redis (phpredis) extension now includes support for collecting the key accessed by a command.

Documentation: Advanced Instrumentation

Tempest support

With Extension 5.34.0, Tideways adds automatic instrumentation for the Tempest framework, one of the newer additions to the PHP ecosystem.

This includes automatically detecting controllers as transaction names, instrumenting the framework’s exception handlers to capture errors, and creating template engine and event spans to provide additional context.

Yii3 support

We also support the new Yii3 version released at the turn of the year. This includes transaction name detection and exception handling.

(Open)LiteSpeed support

The tideways-php Debian Package now also supports installing Tideways for LSWS, installing the Tideways Extension and the INI configuration template into the correct directories.

Polyfill Bottleneck

Tideways now detects if you use a Symfony Polyfill API (Mbstring, Intl, ..) and it takes up enough time of a request that installing the actual PHP extension would give you a significant performance improvement.

Make observations available via REST API

You can now query the REST API for observation data that show detected monitoring, configuration, and code bottlenecks with detailed problem and solution information.

Due to the highly flexible nature of each problem, the list only contains metadata for now and a link to the application where the detailed information is available.

Documentation: Observations API

Dodge the thundering herd with file-based OPcache

In the blog post about Fine-Tuning OPcache Configuration I mentioned the thundering herd problem that affects OPcache during cache restarts.

When OPcache is restarted, either automatically or manually, all current users will attempt to regenerate the cache entries. Under load this can lead to a burst in CPU usage and significantly slower requests.

When running your PHP application inside containers, and potentially AWS Lambda or other serverless function platforms, then there is also the “cold start” problem. The first request is much slower than subsequent requests.

For these scenarios, OPcache comes with a persistent secondary file-based cache. It can read the generated opcodes from disk instead of having to recompile the code after cache restart. This happens only when the compiled opcaches are not found in shared memory.

A persistent cache can fix the problems for high traffic sites that experience thundering herds during deployments, or thecold start problem and allows new deployment patterns that can avoid restarting php-fpm or OPcache directly.

You can control the file cache behavior with three new ini settings:

opcache.file_cache=/var/tmp/php/opcache
opcache.file_cache_only=1 # Useful for CLI
opcache.file_cache_consistency_checks=1

And with PHP 8.5, there is a new setting that allows you to use the file cache with read only disks/volumes:

opcache.file_cache_read_only=1

To compile the scripts into the file cache during deployment, you can recursively iterate over your code files and then call the function opcache_compile_file on them. This will generate the file cache file and then use it from the web context when the application starts receiving traffic for the new deployment / version.

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.

We tried backslashing all the functions

When applications need to transfer cryptographic identifiers into other systems, for example within JSON, they often need to encode them into a safe format. This can however leak information about the secret, unless the encoding is done with a “constant time” algorithm, such as implemented by the paragonie/constant_time_encoding library.

Constant time algorithms are generally slower than the equivalent non-constant time algorithm due to the additional work that needs to be done. Nevertheless we should strive to make them as fast as possible as long as security is maintained.

This blog post discusses the pull request that makes the encoding algorithm quite a bit faster by relying on a simple PHP OPcache optimization.

Pure PHP vs Native Libsodium Implementation

The library is implemented in pure PHP for maximum compatibility across a wide range of web hosting services and PHP versions, which might not necessarily have all optional PHP extensions available. In version 3.1.0, the maintainers, however added optional support for the “sodium” extension, which includes constant time implementations for common encodings. This optional support is implemented by checking for the availability of the sodium extension whenever the encoding function is called and then either using the functions from sodium or the pure PHP implementation.

   // https://github.com/paragonie/constant_time_encoding/blob/master/src/Hex.php
    public static function encode(
        #[\SensitiveParameter]
        string $binString
    ): string {
        if (extension_loaded('sodium')) {
            try {
                return sodium_bin2hex($binString);
            } catch (SodiumException $ex) {
                throw new RangeException($ex->getMessage(), $ex->getCode(), $ex);
            }
        }
        $hex = '';
        $len = Binary::safeStrlen($binString);
        for ($i = 0; $i < $len; ++$i) {
            /** @var array $chunk */
            $chunk = \unpack('C', $binString[$i]);
            $c = $chunk[1] & 0xf;
            $b = $chunk[1] >> 4;

            $hex .= \pack(
                'CC',
                (87 + $b + ((($b - 10) >> 8) & ~38)),
                (87 + $c + ((($c - 10) >> 8) & ~38))
            );
        }
        return $hex;
    }

For a simple test script:


require('vendor/autoload.php');

$secret = random_bytes(16);
$length = 0;
for ($i = 0; $i < 1000000; $i++) {
        $length += strlen(\ParagonIE\ConstantTime\Hex::encode($secret));
}
var_dump($length);

This provided for an impressive 14× improvement in performance with libsodium vs the pure PHP variant:

Benchmark 1: php -d opcache.enable_cli=1 constant_time_encoding_pure_php/test.php
  Time (mean ± σ):      2.012 s ±  0.010 s    [User: 1.993 s, System: 0.018 s]
  Range (min … max):    2.000 s …  2.033 s    10 runs
 
Benchmark 2: php -d opcache.enable_cli=1 constant_time_encoding_sodium/test.php
  Time (mean ± σ):     143.5 ms ±   6.1 ms    [User: 125.6 ms, System: 17.4 ms]
  Range (min … max):   134.5 ms … 158.3 ms    21 runs
 
Summary
  php -d opcache.enable_cli=1 constant_time_encoding_sodium/test.php ran
   14.02 ± 0.60 times faster than php -d opcache.enable_cli=1 constant_time_encoding_pure_php/test.php

Adding Backslashes to All Function Calls

An astute observer might have noticed that some of the functions in the above snippet from the library have a leading backslash, whereas some do not. Adding the backslash is commonly done in security-sensitive code to make sure that a malicious library is not able to “sneak in” fake implementations into the namespace of the security-sensitive library. We should therefore add a leading backslash to the extension_loaded() and sodium_bin2hex() calls as well to make sure we are actually using the Sodium extension and nothing else. We will take a closer look at the following change which is an excerpt from our PR #64.

diff --git a/src/Hex.php b/src/Hex.php
index d4d5259..50f77d1 100644
--- a/src/Hex.php
+++ b/src/Hex.php
@@ -48,9 +48,9 @@ abstract class Hex implements EncoderInterface
         #[\SensitiveParameter]
         string $binString
     ): string {
-        if (extension_loaded('sodium')) {
+        if (\extension_loaded('sodium')) {
             try {
-                return sodium_bin2hex($binString);
+                return \sodium_bin2hex($binString);
             } catch (SodiumException $ex) {
                 throw new RangeException($ex->getMessage(), $ex->getCode(), $ex);
             }

It turns out this also further improves performance to 18× the baseline (1.3× the original sodium implementation):

Benchmark 1: php -d opcache.enable_cli=1 constant_time_encoding_pure_php/test.php
  Time (mean ± σ):      2.130 s ±  0.112 s    [User: 2.115 s, System: 0.014 s]
  Range (min … max):    1.998 s …  2.350 s    10 runs
 
Benchmark 2: php -d opcache.enable_cli=1 constant_time_encoding_sodium/test.php
  Time (mean ± σ):     149.8 ms ±   3.7 ms    [User: 131.4 ms, System: 18.1 ms]
  Range (min … max):   142.9 ms … 157.5 ms    20 runs
 
Benchmark 3: php -d opcache.enable_cli=1 constant_time_encoding_sodium_backslashes/test.php
  Time (mean ± σ):     115.6 ms ±   4.8 ms    [User: 97.5 ms, System: 17.8 ms]
  Range (min … max):   108.2 ms … 129.7 ms    25 runs
 
Summary
  php -d opcache.enable_cli=1 constant_time_encoding_sodium_backslashes/test.php ran
    1.30 ± 0.06 times faster than php -d opcache.enable_cli=1 constant_time_encoding_sodium/test.php
   18.43 ± 1.23 times faster than php -d opcache.enable_cli=1 constant_time_encoding_pure_php/test.php

How the code is optimized by PHP

There are two reasons why this code is much faster:

  1. Without the backslash, PHP will first need to check whether there’s a sodium_bin2hex() in the current namespace before falling back to the global function. This fallback is cached per call and thus only happens once, though.
  2. We wrote about “compiler optimized functions” before, which OPcache evaluates at compile-time. \extension_loaded() is among these functions.

This means that OPcache will replace the extension_loaded() check by just true :

 // Optimization after 1 Iteration
    public static function encode(
        #[\SensitiveParameter]
        string $binString
    ): string {
        if (true) {
            try {
                return \sodium_bin2hex($binString);
            } catch (SodiumException $ex) {
                throw new RangeException($ex->getMessage(), $ex->getCode(), $ex);
            }
        }
        $hex = '';
        $len = Binary::safeStrlen($binString);
        for ($i = 0; $i < $len; ++$i) {
            /** @var array $chunk */
            $chunk = \unpack('C', $binString[$i]);
            $c = $chunk[1] & 0xf;
            $b = $chunk[1] >> 4;

            $hex .= \pack(
                'CC',
                (87 + $b + ((($b - 10) >> 8) & ~38)),
                (87 + $c + ((($c - 10) >> 8) & ~38))
            );
        }
        return $hex;
    }

It will then notice that the if() is useless and remove it:

// Optimization after 2 Iterations
    public static function encode(
        #[\SensitiveParameter]
        string $binString
    ): string {
        try {
            return \sodium_bin2hex($binString);
        } catch (SodiumException $ex) {
            throw new RangeException($ex->getMessage(), $ex->getCode(), $ex);
        }
        $hex = '';
        $len = Binary::safeStrlen($binString);
        for ($i = 0; $i < $len; ++$i) {
            /** @var array $chunk */
            $chunk = \unpack('C', $binString[$i]);
            $c = $chunk[1] & 0xf;
            $b = $chunk[1] >> 4;

            $hex .= \pack(
                'CC',
                (87 + $b + ((($b - 10) >> 8) & ~38)),
                (87 + $c + ((($c - 10) >> 8) & ~38))
            );
        }
        return $hex;
    }

And then the entire code after the try-catch becomes unreachable, since either a return statement will be hit or an Exception will be thrown:

 // Optimization after 3 Iterations
    public static function encode(
        #[\SensitiveParameter]
        string $binString
    ): string {
        try {
            return \sodium_bin2hex($binString);
        } catch (SodiumException $ex) {
            throw new RangeException($ex->getMessage(), $ex->getCode(), $ex);
        }
    }

In the end, the entire pure-PHP implementation is gone and the check whether or not to use it is as well. Instead the Hex::encode() method has become a thin wrapper around \sodium_bin2hex(). This is also visible in the resulting OPcodes.

Before:

ParagonIE\ConstantTime\Hex::encode:
     ; (lines=61, args=1, vars=8, tmps=3)
     ; (after optimizer)
     ; constant_time_encoding_sodium/src/Hex.php:47-73
0000 CV0($binString) = RECV 1
0001 INIT_NS_FCALL_BY_NAME 1 string("ParagonIE\\ConstantTime\\extension_loaded")
0002 SEND_VAL_EX string("sodium") 1
0003 V8 = DO_FCALL_BY_NAME
0004 JMPZ V8 0021
0005 INIT_NS_FCALL_BY_NAME 1 string("ParagonIE\\ConstantTime\\sodium_bin2hex")
0006 SEND_VAR_EX CV0($binString) 1
0007 V8 = DO_FCALL_BY_NAME
0008 VERIFY_RETURN_TYPE V8
0009 RETURN V8
0010 CV1($ex) = CATCH string("SodiumException")
0011 V8 = NEW 3 string("RangeException")
0012 INIT_METHOD_CALL 0 CV1($ex) string("getMessage")
0013 V9 = DO_FCALL
0014 SEND_VAR_NO_REF_EX V9 1
0015 INIT_METHOD_CALL 0 CV1($ex) string("getCode")
0016 V9 = DO_FCALL
0017 SEND_VAR_NO_REF_EX V9 2
0018 SEND_VAR_EX CV1($ex) 3
0019 DO_FCALL
0020 THROW V8
0021 ASSIGN CV2($hex) string("")
0022 INIT_STATIC_METHOD_CALL 1 string("ParagonIE\\ConstantTime\\Binary") string("safeStrlen")
0023 SEND_VAR_EX CV0($binString) 1
0024 V8 = DO_FCALL
0025 ASSIGN CV3($len) V8
0026 ASSIGN CV4($i) int(0)
0027 JMP 0057
0028 INIT_FCALL 2 112 string("unpack")
0029 SEND_VAL string("C") 1
0030 T8 = FETCH_DIM_R CV0($binString) CV4($i)
0031 SEND_VAL T8 2
0032 V8 = DO_ICALL
0033 ASSIGN CV5($chunk) V8
0034 T9 = FETCH_DIM_R CV5($chunk) int(1)
0035 T8 = BW_AND T9 int(15)
0036 ASSIGN CV6($c) T8
0037 T9 = FETCH_DIM_R CV5($chunk) int(1)
0038 T8 = SR T9 int(4)
0039 ASSIGN CV7($b) T8
0040 INIT_FCALL 3 128 string("pack")
0041 SEND_VAL string("CC") 1
0042 T9 = ADD int(87) CV7($b)
0043 T10 = SUB CV7($b) int(10)
0044 T8 = SR T10 int(8)
0045 T10 = BW_AND T8 int(-39)
0046 T8 = ADD T9 T10
0047 SEND_VAL T8 2
0048 T9 = ADD int(87) CV6($c)
0049 T10 = SUB CV6($c) int(10)
0050 T8 = SR T10 int(8)
0051 T10 = BW_AND T8 int(-39)
0052 T8 = ADD T9 T10
0053 SEND_VAL T8 3
0054 V8 = DO_ICALL
0055 ASSIGN_OP (CONCAT) CV2($hex) V8
0056 PRE_INC CV4($i)
0057 T8 = IS_SMALLER CV4($i) CV3($len)
0058 JMPNZ T8 0028
0059 VERIFY_RETURN_TYPE CV2($hex)
0060 RETURN CV2($hex)
LIVE RANGES:
     8: 0008 - 0009 (tmp/var)
     8: 0012 - 0020 (new)
     9: 0043 - 0046 (tmp/var)
     9: 0049 - 0052 (tmp/var)
EXCEPTION TABLE:
     0005, 0010, -, -

After:

ParagonIE\ConstantTime\Hex::encode:
     ; (lines=17, args=1, vars=2, tmps=2)
     ; (after optimizer)
     ; constant_time_encoding_sodium_backslashes/src/Hex.php:47-73
0000 CV0($binString) = RECV 1
0001 INIT_FCALL 1 96 string("sodium_bin2hex")
0002 SEND_VAR CV0($binString) 1
0003 V2 = DO_ICALL
0004 VERIFY_RETURN_TYPE V2
0005 RETURN V2
0006 CV1($ex) = CATCH string("SodiumException")
0007 V2 = NEW 3 string("RangeException")
0008 INIT_METHOD_CALL 0 CV1($ex) string("getMessage")
0009 V3 = DO_FCALL
0010 SEND_VAR_NO_REF_EX V3 1
0011 INIT_METHOD_CALL 0 CV1($ex) string("getCode")
0012 V3 = DO_FCALL
0013 SEND_VAR_NO_REF_EX V3 2
0014 SEND_VAR_EX CV1($ex) 3
0015 DO_FCALL
0016 THROW V2
LIVE RANGES:
     2: 0004 - 0005 (tmp/var)
     2: 0008 - 0016 (new)
EXCEPTION TABLE:
     0001, 0006, -, -

After all the optimizations—both the Sodium support added by the maintainer and the backslashes added by the featured PR #64—, the hexadecimal encoder now only has a roughly 2.1× overhead over the non-constant-time bin2hex() when the Sodium extension is installed. This makes it feasible to err on the side of caution and use the constant time encoder as the default choice, falling back to the insecure alternatives only when best performance is required for a confirmed non-security-sensitive use case.

Benchmark 1: php -d opcache.enable_cli=1 constant_time_encoding_pure_php/test.php
  Time (mean ± σ):      2.010 s ±  0.009 s    [User: 1.993 s, System: 0.016 s]
  Range (min … max):    1.995 s …  2.025 s    10 runs
 
Benchmark 2: php -d opcache.enable_cli=1 constant_time_encoding_sodium/test.php
  Time (mean ± σ):     143.5 ms ±   6.8 ms    [User: 126.6 ms, System: 16.7 ms]
  Range (min … max):   133.4 ms … 159.4 ms    20 runs
 
Benchmark 3: php -d opcache.enable_cli=1 constant_time_encoding_sodium_backslashes/test.php
  Time (mean ± σ):     107.8 ms ±   3.4 ms    [User: 90.5 ms, System: 17.0 ms]
  Range (min … max):   102.6 ms … 115.3 ms    28 runs
 
Benchmark 4: php -d opcache.enable_cli=1 bin2hex/test.php
  Time (mean ± σ):      52.0 ms ±   3.4 ms    [User: 38.1 ms, System: 13.6 ms]
  Range (min … max):    46.6 ms …  63.9 ms    60 runs
 
Summary
  php -d opcache.enable_cli=1 bin2hex/test.php ran
    2.07 ± 0.15 times faster than php -d opcache.enable_cli=1 constant_time_encoding_sodium_backslashes/test.php
    2.76 ± 0.22 times faster than php -d opcache.enable_cli=1 constant_time_encoding_sodium/test.php
   38.62 ± 2.53 times faster than php -d opcache.enable_cli=1 constant_time_encoding_pure_php/test.php

The same optimization is applicable not just to functions, but also to constants. If the constant is provided by an extension and the name is fully-qualified, this allows OPcache to insert the constant’s value at compile time, which then might allow to fully evaluate individual expressions. A PHP version-compatibility check using if (\PHP_VERSION_ID < 80500) { /* … */ } would be an example. The compatibility code within the if() will only exist for PHP versions below PHP 8.5, and the if() will be fully removed at runtime when upgrading to PHP 8.5.

Time to get started with Profiling! With our free trial.

PHP 8.5 Garbage Collection Improvements

Big optimizations in PHP’s memory consumption have become exceedingly rare. Since then, memory improvements have been smaller in scope, focusing on little details for certain types of variables.

Like improving the Garbage Collector (GC) in edge cases, which Iljia Tovilo contributed to PHP 8.5 titled “Mark enums and static fake closures as not collectable”.

In short, this means that for these two variable types, the cycle collector will not attempt to “collect” them, preventing unnecessary runs if lots of those instances are in use.

This does not come with any downsides, as these types cannot be cyclic and are therefore not subject to the GC anyway.

Quick Detour: How does PHP’s Garbage Collector work?

To understand why this is useful, we should remind ourselves quickly how garbage collection in PHP operates. For more details, you can take a look at our blog post and podcast episode on the topic.

In short, PHP’s GC does not run continuously but is triggered by a threshold. The threshold being that there are currently at least 10,000 possibly cyclic objects or arrays in memory. If the number of these instances increases without the possibility to clean up, the GC might be running over increasingly large numbers of objects, consuming valuable computational resources.

As a result, reducing the number of objects eligible for garbage collection reduces the chance of GC runs and therefore improves performance.

One neat function, added in PHP 7.3, was not mentioned in our blog post. With gc_status() (Docs) it is quite easy to gain insights into garbage collection performance.

What do we actually gain?

Looking at the example from the pull request, we can see that plenty of instances of first class callables are added to and iterated over afterward.



function foo() {}

$array = [];
for ($i = 0; $i < 10_000_000; $i++) {
    $array[] = foo(...);
}

//
// The loop is needed to make sure that the cycle collector is triggered:
// - `as $foo` increases the refcount of the array elements 
// - which is decreased again after the variable runs out of scope
//
foreach ($array as $foo) {}

echo json_encode(gc_status(), JSON_PRETTY_PRINT);

With older PHP versions, this will trigger the cycle collector to check all of those instances for cyclic references. Since these instances cannot have cyclic references, these checks are unnecessary. With 10 million entries, this resulted in 44 runs, while anything from PHP 8.5 onwards has 0 runs with the same code.

Definition: “static fake closures”

The pull request’s title states that the optimization applies to “enums and static fake closures”. Since static fake closures are definitely the more relevant case and the terminology does not match userland namings, let’s dive into this: what are “static fake closures”?

The example (see last chapter) does use “static fake closures”, which are commonly called “first-class callables” in userland. In this example, this means foo(...).

To determine if a callable is a “fake closure”, you can run a check if the anonymous flag returns false on the callable’s reflection class, i.e., (new \\ReflectionFunction($callable))->isAnonymous();.

The “static” part of the type refers to what we understand in OOP as static: not having access to an object instance (i.e., $this).

In this list of different closures, you can see which ones are considered static fake closures by the PR and which are not:



class Foo 
{
    public static function bar(): void {}
    public function baz(): void {}    
}

function bar(): void {}

//
// static fake closures
//
var_dump(...);
bar(...);
Foo::bar(...);
$callable = Closure::fromCallable('bar');
$callable(...);

// fake closure, but non-static
(new Foo())->baz(...);
// anonymous function, no fake closure
$anon = static function () {};
$anon(...);

The Enum Case

The second type that was optimized are instances of Enum. This will most likely have relatively minimal effects in real projects, as this would only impact performance if a codebase contains many Enums with many cases. Enum instances are singletons, so each Enum’s case is instantiated exactly once. This would not have any effect if there are thousands of references to the same three or four enum cases, since each enum case can only appears once in the GC’s root buffer.

Conclusion

Tiny wins like these show that PHP’s memory management can still be improved while already being quite mature.

This change might improve performance in some cases, but most importantly, it makes sure that garbage collection runs do not waste time on irrelevant checks.

Thanks for the contribution, Iljia Tovilo.

Time to get started with Profiling! With our free trial.

Tideways 2025.4 Release

In our fourth Release of 2025, we included PHP 8.5 support on the day of its release and Heartbeats to monitor your application’s pulse more closely than ever. If something is offbeat, you’ll receive timely alerts.

We improved alerting by introducing fine-granular transaction level response time and made automatic tracepoint triggers more powerful.

Summary

PHP 8.5 Support

PHP 8.5 has been released last week and Tideways – as always – supports it from the start with the PHP Extension Release 5.30.0.

Heartbeat Monitoring per Transaction

Some transactions (think cron jobs, payment completion, webhooks) are so critical for web applications that it is a sign of failure or at least a problem when no data was processed for a few minutes or hours.

Tideways can now send notifications on missing transaction check-ins at a granularity of 1 minute and more. For any given transaction, you can configure an individual check-in interval in the transaction settings.

Heartbeat Monitoring

Furthermore, we have renamed the “Missing Data” notifications on the service level to “Heartbeat Monitoring” because this name is clearer and well established.

Check our documentation for more detail.

Improved Alerting on Transaction Response Times

Alerting for response time thresholds on a per-transaction basis was previously exclusive to only bigger plans. We have completely rebuilt how it works to allow a much wider audience to benefit from this feature.

You can now configure the notification under transaction settings, just like all the other transaction-level notifications.

Improved Alerting

You will notice an added line in the performance chart for the transactions on which you enabled this feature.

Response Times

When the response is slower than expected for 5% of all requests, then an incident is created and notifications may be sent:

Improvement Transaction List Filters

Because of these new transaction notification options for heartbeats and response times, we worked on a few small improvements for the Transaction settings list:

  • You can now see at a glance which notifications are set per transaction.
  • There are filters available to only select transactions with selected notifications configured.

More nodes in Callgraphs

Callgraphs can become quite complex quickly. After all, every function call is tracked. With modern frameworks or ecommerce solutions, this can be hundreds or thousands of different functions being recorded.

Visualizing this data can be a challenge. Which is why we limit the number of visible nodes by a simple algorithm: take the three most costly function calls and build the graph upwards from there. The full list of function calls has always been available using the table on the left anyway.

But since it is helpful in some cases to see a wider range of nodes, we added an option to change this start-count using a dropdown. This might be useful, e.g., with long-running scripts, where there might be more than a handful of important calls.

Automatic Tracepoints Trigger

After introducing automatic tracepoints in the last release, there is now another strategy to create a tracepoint automatically: It picks one of the seven most high-impact transactions that are rotated every week.

This will provide you with callgraph insights into different parts of the application automatically, and in combination with the trace history this data is available over a long period of time as well.

This strategy is available for Business and Enterprise plans and is used automatically for triggers created for new services. Alternatively you can select the strategy from the settings called “Rotate between highest impact transactions”.

More Stacktraces on Spans

We’ve changed the default INI settings that control when stack traces are added to spans in a trace to 1 ms (from previously 3 ms) and increased the stack depth to 10 (previously 5). Overall, this should make stack traces more valuable for pinpointing performance problems.

PDO Transactions

Long-running database transactions can have a significant impact on database performance and should thus be active as long as necessary but as short as possible. Tideways already included individual spans for BEGIN and COMMIT / ROLLBACK queries, indicating the start and end of a transaction and the COMMIT performance.

Now Tideways will also create a “meta span” spanning the entire transaction, making it easier to see at a glance which queries are part of the transaction and for how long the transaction was active. Since rollbacks can be particularly expensive and generally indicate issues, the spans for rolled-back transactions will also be marked as “errored”.

PDO Transactions

Incoming Network for Predis

When using the predis/predis library, spans will now contain a Network (incoming) annotation, just like HTTP calls or MySQL queries.

Magento Sections

To improve the efficiency of the page cache, Magento retrieves dynamic user-specific components of the resulting page in bulk from a special “Section Loader” endpoint. Traces for this endpoint now contain spans for each component loaded, helping you find out which components are particularly expensive to calculate.

Minor PHP Extension Improvements

This release, as always, contains some minor improvements to various features. Amongst those is extending support for the automated worker instrumentation to Yii 2 Queues, support for transaction name detection with Slim 4 and extended detection of email addresses in error messages and URLs in Tideways Daemon to prevent them from leaving your infrastructure.

Check our detailed changelog for more detail.

Time to get started with Profiling! With our free trial.

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.

Debugging a running PHP process by attaching GDB

We are noticing that some of our requests are starting to get slow and server load increases. Checking the process list of our server, for example with htop reveals that our FPM workers are taking up all of our CPU time.

Checking the health with our basic toolset of lsof to show open network connections and strace to show syscalls does not reveal any activity. This means that the workers are spending time processing data without any externally visible activity. This could be an infinite loop or just an inefficient algorithm.

Unfortunately, we were never able to reproduce the issue ourselves, so our regular PHP debugging toolkit in our development setup doesn’t work. How can we get additional information from these processes when we catch them red-handed?

Using a system-level debugger, specifically gdb, comes to the rescue. On common Linux distributions, it is available within the aptly named package gdb. We’re using Debian Trixie for this post but have made available instructions for other distributions at the bottom.

Let’s start by installing gdb:

apt install gdb

Besides gdb, we also need debug symbols for PHP. These debug symbols allow gdb to map the processor instructions back to the statements and variables in the C source code of PHP. Since we are using Debian Trixie’s native PHP 8.4 packages, these debug symbols are available with debuginfod, which we enable by setting an environment variable:

export DEBUGINFOD_URLS="https://debuginfod.debian.net"

Setup is now finished, and we can launch gdb using the gdb command. We need the PID of one of our stuck workers, 7154 in this case, and then use attach $pid (thus attach 7154) to attach to the worker. Note that the worker will be completely frozen while we look at it in gdb; thus, the request that is currently processed might run into timeouts depending on how long the worker is stopped with gdb. Use detach after extracting all the relevant information to let it continue running from where it stopped.

The bt command allows you to retrieve a C-level backtrace, which can already provide some insight.

# gdb
(gdb) attach 7154
Attaching to process 7154
Reading symbols from /usr/sbin/php-fpm8.4...

This GDB supports auto-downloading debuginfo from the following URLs:
  
Enable debuginfod for this session? (y or [n]) y
[…]
0x000055ae695bc9aa in execute_ex (ex=0x7f20d3414330) at ./Zend/zend_vm_execute.h:61167
warning: 61167	./Zend/zend_vm_execute.h: No such file or directory
(gdb) bt
#0  0x000055ae695bf264 in ZEND_ADD_SPEC_TMPVARCV_TMPVARCV_HANDLER () at ./Zend/zend_vm_execute.h:14023
#1  execute_ex (ex=0x60) at ./Zend/zend_vm_execute.h:60460
#2  0x000055ae695ba345 in zend_execute (op_array=op_array@entry=0x7f20d3467000, return_value=return_value@entry=0x0)
    at ./Zend/zend_vm_execute.h:64308
#3  0x000055ae69625814 in zend_execute_script (type=type@entry=8, retval=retval@entry=0x0, 
    file_handle=file_handle@entry=0x7fff00207980) at ./Zend/zend.c:1934
#4  0x000055ae694b8c50 in php_execute_script_ex (primary_file=, retval=retval@entry=0x0)
    at ./main/main.c:2575
#5  0x000055ae694b8edb in php_execute_script (primary_file=) at ./main/main.c:2615
#6  0x000055ae69337251 in main (argc=, argv=) at ./sapi/fpm/fpm/fpm_main.c:1932

We are stuck in ZEND_ADD_SPEC_TMPVARCV_TMPVARCV_HANDLER from zend_vm_execute.h, meaning we are adding two values in userland code, but additions can happen everywhere, which is not particularly useful to find out what is going wrong. It would be nice if we could find out which line of PHP code is currently being executed. Ideally with a full PHP-level stacktrace.

Turns out this is possible! PHP provides an official gdb script that provides helpful commands to retrieve information from PHP. It’s available as .gdbinit in the root of the php/php-src repository. We’ll need to make sure to select the right branch, PHP-8.4 in our case: https://github.com/php/php-src/blob/PHP-8.4/.gdbinit

After placing it somewhere convenient on the server, we load it into gdb using the source command:

(gdb) source /tmp/.gdbinit

Now the zbacktrace function will be available, which will output a stacktrace containing the names of the PHP functions rather than the C code executed internally.

(gdb) zbacktrace
[0x7f20d34143c0] FibonacciService->__invoke(3) /var/www/html/fibonacci.php:10 
[0x7f20d3414330] FibonacciService->__invoke(4) /var/www/html/fibonacci.php:9 
[0x7f20d34142a0] FibonacciService->__invoke(5) /var/www/html/fibonacci.php:9 
[0x7f20d3414210] FibonacciService->__invoke(6) /var/www/html/fibonacci.php:9 
[0x7f20d3414180] FibonacciService->__invoke(7) /var/www/html/fibonacci.php:9 
[0x7f20d34140f0] FibonacciService->__invoke(8) /var/www/html/fibonacci.php:9 
[0x7f20d3414060] FibonacciService->__invoke(9) /var/www/html/fibonacci.php:9 
[0x7f20d3413fd0] FibonacciService->__invoke(11) /var/www/html/fibonacci.php:10 
[0x7f20d3413f40] FibonacciService->__invoke(12) /var/www/html/fibonacci.php:9 
[0x7f20d3413eb0] FibonacciService->__invoke(14) /var/www/html/fibonacci.php:10 
[0x7f20d3413e20] FibonacciService->__invoke(15) /var/www/html/fibonacci.php:9 
[0x7f20d3413d90] FibonacciService->__invoke(16) /var/www/html/fibonacci.php:9 
[0x7f20d3413d00] FibonacciService->__invoke(18) /var/www/html/fibonacci.php:10 
[0x7f20d3413c70] FibonacciService->__invoke(20) /var/www/html/fibonacci.php:10 
[0x7f20d3413be0] FibonacciService->__invoke(21) /var/www/html/fibonacci.php:9 
[0x7f20d3413b50] FibonacciService->__invoke(22) /var/www/html/fibonacci.php:9 
[0x7f20d3413ac0] FibonacciService->__invoke(23) /var/www/html/fibonacci.php:9 
[0x7f20d3413a30] FibonacciService->__invoke(24) /var/www/html/fibonacci.php:9 
[0x7f20d34139a0] FibonacciService->__invoke(25) /var/www/html/fibonacci.php:9 
[0x7f20d3413910] FibonacciService->__invoke(26) /var/www/html/fibonacci.php:9 
[0x7f20d3413880] FibonacciService->__invoke(28) /var/www/html/fibonacci.php:10 
[0x7f20d34137f0] FibonacciService->__invoke(30) /var/www/html/fibonacci.php:10 
[0x7f20d3413760] FibonacciService->__invoke(31) /var/www/html/fibonacci.php:9 
[0x7f20d34136d0] FibonacciService->__invoke(33) /var/www/html/fibonacci.php:10 
[0x7f20d3413640] FibonacciService->__invoke(35) /var/www/html/fibonacci.php:10 
[0x7f20d34135b0] FibonacciService->__invoke(36) /var/www/html/fibonacci.php:9 
[0x7f20d3413520] FibonacciService->__invoke(37) /var/www/html/fibonacci.php:9 
[0x7f20d3413490] FibonacciService->__invoke(39) /var/www/html/fibonacci.php:10 
[0x7f20d3413400] FibonacciService->__invoke(40) /var/www/html/fibonacci.php:9 
[0x7f20d3413370] FibonacciService->__invoke(41) /var/www/html/fibonacci.php:9 
[0x7f20d34132e0] FibonacciService->__invoke(42) /var/www/html/fibonacci.php:9 
[0x7f20d3413250] FibonacciService->__invoke(43) /var/www/html/fibonacci.php:9 
[0x7f20d34131c0] FibonacciService->__invoke(44) /var/www/html/fibonacci.php:9 
[0x7f20d3413130] FibonacciService->__invoke(45) /var/www/html/fibonacci.php:9 
[0x7f20d3413020] (main) /var/www/html/fibonacci.php:26 

Oh, that is nice. Apparently we are stuck evaluating a deep tree of Fibonacci numbers. Let’s check what data they are working on. We can print all local variables (internally called “CV”) using the print_cvs command, which also was provided by the .gdbinit script. We’ll need to pass the ID of the stack frame we’re interested in, FibonacciService->__invoke(33) in this case:

(gdb) print_cvs 0x7f20d34136d0
Compiled variables count: 3

[0] '$n'
[0x7f20d3413720] long: 33
[1] '$n1'
[0x7f20d3413730] long: 2178309
[2] '$n2'
[0x7f20d3413740] UNDEF

$n is 33 (which makes sense). It already calculated $n1 which is 2178309, the 32nd Fibonacci number, $n2 (the 31st) is not yet available, which also makes sense, since the function would be finished already otherwise – except for the addition, which should be so fast that it is challenging to catch reliably.

Now we know why the worker is using so much CPU, but we still don’t know what data was included in the request (e.g., $_GET parameters) to reach this situation. This information is available from the superglobals. To access them, we first need to obtain a list of all global variables using the print_global_vars command:


(gdb) print_global_vars
Hash(6)[0x55ae697e3e90]: {
  [0] "_GET" => [0x7f20d3461200] (refcount=2) array: 
  [1] "_POST" => [0x7f20d3461220] (refcount=2) array: 
  [2] "_COOKIE" => [0x7f20d3461240] (refcount=2) array: 
  [3] "_FILES" => [0x7f20d3461260] (refcount=2) array: 
  [4] "n" => [0x7f20d3461280] indirect: [0x7f20d3413070] long: 45

  [5] "service" => [0x7f20d34612a0] indirect: [0x7f20d3413080] (refcount=35) object(FibonacciService) #1

}

Afterwards, we can print out an individual variable using the printzv command with the variable pointer as the parameter ($_GET in this case):


(gdb) printzv 0x7f20d3461200
[0x7f20d3461200] (refcount=2) array:     Hash(1)[0x7f20d345b000]: {
      [0] "n" => [0x7f20d345c180] (refcount=1) string: 45
}

Okay, our script was accessed with ?n=45 as the query string. Testing this in our development setup confirms it hangs, which means we can now start debugging and optimizing it!

Appendix

Debian Native Packages

The Debian Wiki explains how to get a backtrace. The easiest solution is by enabling debuginfod, as we have done above:

export DEBUGINFOD_URLS="<https://debuginfod.debian.net>"

Alternatively the main/debug APT archive needs to be enabled and the corresponding -dbgsym package for each PHP package needs to be installed. Most importantly the phpX.Y-cli-dbgsym or phpX.Y-fpm-dbgsym package is required, since it provides the debug symbols for the Zend Engine.

Debian with Sury’s PPA

The -dbgsym packages need to be installed as explained in the section for Debian Native Packages. No changes to the sources list are required, since the -dbgsym packages are part of the main archive.

Ubuntu Native Packages

On recent Ubuntu versions debuginfod is enabled by default and everything should work out of the box. In other cases, the Ubuntu Server documentation about debuginfod explains the setup.

Ubuntu with Sury’s PPA

The main/debug APT archive needs to be enabled by adding the main/debug component to your sources list of the PPA and -dbgsym packages need to be installed, as explained in the section for Debian Native Packages.

The Ubuntu Wiki on Debugging Program Crashes provides more detail.

RockyLinux Native Packages

debuginfod is enabled by default, but it appears to be unavailable in our test. Instead debuginfo packages can manually be installed via:

dnf debuginfo-install php-cli […]

AlmaLinux Native Packages

debuginfod does not appear to be set up. Debuginfo packages can be installed as explained in the section for RockyLinux Native packages.

Docker

No prebuilt debug symbols are available. Instead the Dockerfiles available in the docker-library/php GitHub repository need to be modified to remove the logic that strips the debug symbols and the Docker image needs to be rebuilt from the modified Dockerfile.

More details are available in docker-library/php#1538.

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.