◀ Back to blog
PHP / Symfony

PHP performance optimization

Published on 02 Jun 2024· 9 min read
#PHP#Performance#OPcache

Optimizing PHP for production

PHP performance is crucial for high-traffic applications. At CCM Benchmark, where sites handle millions of daily visitors, every millisecond counts.

Optimizing does not mean rewriting everything. In the vast majority of applications, the gains come from a few well-chosen settings: a properly configured OPcache, an optimized autoloader, well-controlled SQL queries and a PHP-FPM pool sized according to the available memory. The golden rule stays the same at every step: measure before optimizing, then measure again to confirm the gain. This article follows that order, from the most cost-effective setting to the most specific one.

OPcache: the foundation

On every request, PHP normally has to read, parse and compile the source files into bytecode. OPcache keeps that bytecode in shared memory: subsequent requests execute it directly. It is the setting with the best effort-to-gain ratio, and it must be enabled on every production server.

; php.ini - Optimal OPcache configuration
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.save_comments=1
opcache.preload=/var/www/app/config/preload.php
opcache.preload_user=www-data

What these directives do:

  • memory_consumption (in MB) must hold all the bytecode of the application and its dependencies. If it fills up, OPcache stops caching new files or restarts, and performance drops without any visible error.
  • max_accelerated_files must exceed the number of PHP files in the project, vendor/ included. A find . -name '*.php' | wc -l gives the order of magnitude.
  • validate_timestamps=0 removes the modification-time check on every request. The trade-off: you must reload PHP-FPM on every deployment, otherwise the old code keeps running.
  • save_comments=1 is essential if you use annotations read through reflection (older versions of Doctrine or validation libraries).

To check the actual state of the cache, opcache_get_status() returns the hit rate (opcache_hit_rate), the memory in use and the number of restarts. On a stable application, the hit rate should stay very close to 100%.

Complete this with the realpath cache, which avoids repeated system calls to resolve file paths:

; php.ini - realpath cache
realpath_cache_size=4096K
realpath_cache_ttl=600

PHP 8 preloading

Since PHP 7.4, preloading loads a set of classes into memory when the server starts. They stay available to every request, without even going through the autoloader.

// config/preload.php
require dirname(__DIR__).'/vendor/autoload.php';

// Preload frequently used classes
$files = glob(dirname(__DIR__).'/src/Entity/*.php');
foreach ($files as $file) {
    opcache_compile_file($file);
}

With Symfony, there is no need to write this list by hand: the config/preload.php file provided by the recipe includes var/cache/prod/App_KernelProdContainer.preload.php, generated during cache:warmup with the classes the container actually uses. Two precautions: any change to a preloaded class requires a full PHP-FPM restart, and the gain depends on the application. It is often modest once OPcache is well tuned, so measure it before keeping it.

An optimized autoloader

In production, Composer can generate a complete classmap, which avoids checking for files on disk for every class:

composer install --no-dev --optimize-autoloader --classmap-authoritative
composer dump-env prod
APP_ENV=prod php bin/console cache:warmup

--classmap-authoritative tells the autoloader to look for classes only in the classmap. It is fast, but a class generated at runtime and missing from the classmap will not be found. composer dump-env prod compiles the .env files into a PHP file, which avoids parsing them on every request.

Profiling with Blackfire

Optimizing without profiling is guesswork. A profiler shows exactly where time and memory are spent: function by function, SQL query by SQL query, HTTP call by HTTP call. Blackfire is designed to be usable in production with zero overhead when not profiling. Installation goes through the official APT repository:

# Add the Blackfire repository (Debian/Ubuntu)
wget -q -O - https://packages.blackfire.io/gpg.key | sudo dd of=/usr/share/keyrings/blackfire-archive-keyring.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/blackfire-archive-keyring.asc] http://packages.blackfire.io/debian any main" | sudo tee /etc/apt/sources.list.d/blackfire.list
sudo apt update

# Install the Blackfire agent and the PHP probe
sudo apt install blackfire blackfire-php
sudo blackfire agent:config

# Profile a request
blackfire curl http://localhost/api/products

Read the profile starting from the nodes with the highest "exclusive" time: they are the ones actually doing the work. A call repeated hundreds of times, often an SQL query inside a loop, is the most frequent signal. If Blackfire is not an option, the Xdebug profiler or the open source SPX extension provide comparable information, to be kept for development environments.

Doctrine optimizations

In a Symfony application, the database is almost always the biggest cost. The main levers:

  • Enable the query cache and the result cache
  • Use DQL queries with partial selections
  • Avoid lazy loading with explicit joins
  • Configure the second-level cache

The query cache (the DQL-to-SQL translation) and the result cache are configured in config/packages/doctrine.yaml. The Symfony recipe already enables a configuration of this kind for the production environment:

when@prod:
    doctrine:
        orm:
            query_cache_driver:
                type: pool
                pool: doctrine.system_cache_pool
            result_cache_driver:
                type: pool
                pool: doctrine.result_cache_pool

    framework:
        cache:
            pools:
                doctrine.result_cache_pool:
                    adapter: cache.app
                doctrine.system_cache_pool:
                    adapter: cache.system

The result cache is then enabled query by query:

// Doctrine query cache
$query = $em->createQuery('SELECT p FROM App\Entity\Product p')
    ->enableResultCache(3600, 'products_list');

$results = $query->getResult();

Think about invalidation: when a product changes, delete the relevant entry, for example with $em->getConfiguration()->getResultCache()?->deleteItem('products_list'). A cache without an invalidation strategy always ends up serving stale data.

The most common problem remains "N+1": one query to load a list, then one extra query per item as soon as a relation is accessed. An explicit join with addSelect loads everything at once, and selecting into a DTO avoids hydrating full entities when you only need a few fields:

// Explicit join: categories are loaded in the same query
$products = $em->createQueryBuilder()
    ->select('p', 'c')
    ->from(Product::class, 'p')
    ->leftJoin('p.category', 'c')
    ->where('p.active = :active')
    ->setParameter('active', true)
    ->getQuery()
    ->getResult();

// Partial selection into a DTO, without entity hydration
$items = $em->createQuery(
    'SELECT NEW App\Dto\ProductListItem(p.id, p.name, p.price) FROM App\Entity\Product p'
)->getResult();

For batch processing (imports, exports), use toIterable() and call $em->clear() regularly so that the EntityManager does not keep thousands of objects in memory. And do not forget indexes: the Symfony debug toolbar shows every query, and an EXPLAIN on the slowest ones quickly reveals a missing index.

PHP-FPM tuning

PHP-FPM manages a pool of processes that handle requests. Too few processes and requests queue up; too many and the server runs out of memory and starts swapping, which is far worse.

; PHP-FPM configuration for high performance
pm = dynamic
pm.max_children = 50
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 20
pm.max_requests = 500

These values are not meant to be copied: they must be calculated. pm.max_children is roughly the memory available to PHP divided by the average memory of one process. With 4 GB reserved for PHP and 80 MB processes, you get 50. The actual process memory is measured on the server:

# Average memory (in MB) of PHP-FPM processes
ps -o rss= -C php-fpm8.3 | awk '{ sum += $1; n++ } END { if (n) printf "%.0f\n", sum / n / 1024 }'

pm.max_requests recycles each process after 500 requests, which limits the impact of any memory leaks. To find out whether the pool is correctly sized, enable the status page and the slow request log:

; Pool monitoring
pm.status_path = /fpm-status
request_slowlog_timeout = 5s
slowlog = /var/log/php-fpm/slow.log

If the status page often shows max children reached or a non-empty queue (listen queue), the pool is undersized, or requests are too slow. The slowlog then shows exactly which function was blocking. Protect the status URL so that it is only reachable internally.

Production checklist

  1. OPcache enabled, with a hit rate close to 100% and no unexpected restarts.
  2. APP_ENV=prod, APP_DEBUG=0, Symfony cache warmed up on deployment.
  3. Optimized autoloader and development dependencies excluded.
  4. No N+1 on the main pages, and indexes on filtered or sorted columns.
  5. PHP-FPM pool sized according to measured memory, with status and slowlog enabled.
  6. A Blackfire profile (or equivalent) of critical pages before and after each optimization.

Beyond PHP itself, the most powerful lever is often not to run PHP at all: HTTP caching (Cache-Control headers, a reverse proxy or a CDN) for public pages, and heavy work offloaded to asynchronous workers. A request served from a cache costs almost nothing, whatever the quality of the code behind it.