AΒ·R
EN SR

PHP Performance: What Actually Moves the Needle

Exact OPcache configurations, PHP-FPM pool settings, Redis object caching, Composer autoloader optimization, and profiling tools that deliver measurable performance gains in production.

PHP Performance: What Actually Moves the Needle

OPcache Configuration That Actually Works

OPcache is a bytecode cache β€” it stores compiled PHP scripts in shared memory, eliminating the need to parse and compile scripts on every request. Without OPcache, every single PHP request recompiles every included file. The performance difference is 3-5x on real workloads. Here is the exact configuration for /etc/php/8.2/fpm/php.ini or /etc/php/8.2/cli/php.ini:

; Enable OPcache
opcache.enable=1
opcache.enable_cli=1

; Memory consumption β€” 256MB handles most WordPress sites with 50+ plugins
; For large sites or multisite, increase to 512MB
opcache.memory_consumption=256

; Number of cached scripts β€” 20000 is generous for WordPress
; Default is often 10000 which fills up on heavy sites
opcache.max_accelerated_files=20000

; Validation frequency β€” 0 means never revalidate in production
; Set to 2 (seconds) during development only
opcache.revalidate_freq=0

; Interned strings buffer β€” 16MB handles WordPress well
opcache.interned_strings_buffer=16

; Fast shutdown β€” reduces memory fragmentation
opcache.fast_shutdown=1

; Save comments for reflection/docblock reading (needed by many frameworks)
opcache.save_comments=1

; Memory consumption percentage before restart
opcache.max_wasted_percentage=5

The opcache.revalidate_freq=0 setting means OPcache will never check if a file has changed on disk. In production, you should deploy by restarting PHP-FPM rather than relying on timestamp checks. Add this to your deployment script:

# Restart PHP-FPM after code deployment
sudo systemctl restart php8.2-fpm

# Or use a reload for zero-downtime deployments on newer systems
sudo systemctl reload php8.2-fpm

To verify OPcache is working and properly sized, check the OPcache GUI or use this snippet:

<?php
$status = opcache_get_status(true);
echo 'Scripts cached: ' . count($status['scripts']) . "n";
echo 'Memory used: ' . round($status['memory_usage']['used_memory'] / 1024 / 1024, 2) . " MBn";
echo 'Memory free: ' . round($status['memory_usage']['free_memory'] / 1024 / 1024, 2) . " MBn";
echo 'Hit rate: ' . round($status['opcache_statistics']['opcache_hit_rate'], 2) . "%n";

# If hit rate is below 98%, your cache is too small or not warming properly.

Realpath Cache Tuning

PHP resolves relative paths and symlinks through the realpath() system call, caching the results to avoid repeated filesystem lookups. The defaults are too small for WordPress sites that load hundreds of files per request. In php.ini:

; Realpath cache β€” critical for sites with many included files
realpath_cache_size=16M
realpath_cache_ttl=600

A WordPress request with 40 plugins can easily trigger 300+ include/require calls. Without adequate realpath cache, PHP hits the filesystem repeatedly for the same path resolutions. Set realpath_cache_size to at least 16MB β€” the default is typically 4MB which fills quickly. The TTL of 600 seconds means cached entries expire after 10 minutes, balancing freshness against repeated lookups.

Composer Optimized Autoloader

Composer’s default autoloader uses PSR-4/PSR-0 resolution at runtime, checking the filesystem for class locations. In production, you want a classmap that eliminates all runtime lookups. Run these commands as part of your deployment:

# Optimize autoloader with classmap β€” run this on every deployment
composer dump-autoload --optimize --classmap-authoritative

# --optimize converts PSR-4 autoloading to a classmap
# --classmap-authoritative prevents fallback to PSR-4 if class not in map

# For maximum performance (no dev dependencies)
composer install --no-dev --optimize-autoloader --classmap-authoritative

The --classmap-authoritative flag tells Composer to never fall back to PSR-4 resolution. If a class is not in the classmap, it will fail immediately rather than searching the filesystem. This eliminates all stat calls related to class loading. For a WordPress site loading 200+ classes per request, this reduces per-request stat calls from hundreds to zero.

Redis Object Caching for WordPress

WordPress has a built-in object cache API but no persistent backend by default. Install the Redis Object Cache plugin and configure it in wp-config.php:

/** Enable WP Cache */
define('WP_CACHE', true);

/** Redis Object Cache configuration */
define('WP_REDIS_HOST', '127.0.0.1');
define('WP_REDIS_PORT', 6379);
define('WP_REDIS_DATABASE', 0);
define('WP_REDIS_PREFIX', 'site1_');
define('WP_REDIS_TIMEOUT', 1);
define('WP_REDIS_READ_TIMEOUT', 1);

// Optional: Use igbinary serializer for smaller memory footprint
define('WP_REDIS_SERIALIZER', 'igbinary');

Install Redis and the PHP extension:

# Ubuntu/Debian
sudo apt-get install redis-server php-redis
sudo systemctl enable redis-server

# Verify Redis is running
redis-cli ping
# Should return PONG

# Monitor cache hit rates in real time
redis-cli monitor

With object caching enabled, WordPress caches query results, transients, and option values in Redis instead of the database. On a typical page load, WordPress executes 20-50 database queries. With Redis object caching, this drops to 2-5 queries. The difference becomes dramatic under load β€” database CPU usage typically drops 60-80%.

After enabling, verify it is working by checking Query Monitor or running:

redis-cli info stats | grep keyspace
# Look for keyspace_hits and keyspace_misses
# Hit rate should be above 85% after warmup

Nginx Configuration Specifics

Nginx handles static files directly and passes PHP to PHP-FPM. Here is the server block configuration optimized for WordPress:

server {
    listen 80;
    server_name example.com;
    root /var/www/html;
    index index.php;

    # Enable gzip for text assets
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss application/rss+xml font/truetype font/opentype application/vnd.ms-fontobject image/svg+xml;

    # Browser caching for static assets
    location ~* .(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2)$ {
        expires 6M;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    # PHP handling via PHP-FPM Unix socket
    location ~ .php$ {
        try_files $uri =404;
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;

        # Timeouts and buffer tuning
        fastcgi_read_timeout 300;
        fastcgi_buffer_size 128k;
        fastcgi_buffers 256 16k;
        fastcgi_busy_buffers_size 256k;
        fastcgi_temp_file_write_size 256k;
    }

    # WordPress permalinks
    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    # Deny access to sensitive files
    location ~ /. { deny all; }
    location ~ wp-config.php { deny all; }
}

The fastcgi_buffers settings matter for WordPress admin pages that generate large HTML responses. Default buffer sizes cause Nginx to write temporary files to disk, adding latency. The 256x16k buffer configuration handles pages up to 4MB without disk buffering.

PHP-FPM Pool Settings

Edit /etc/php/8.2/fpm/pool.d/www.conf:

; Static pool size β€” set based on available RAM and worker memory usage
; Each PHP-FPM worker on WordPress uses ~40-80MB RSS
; For a 4GB server: 4000MB / 60MB = ~66 workers, leave headroom = 50
pm = static
pm.max_children = 50

; If using dynamic instead (for shared hosting):
; pm = dynamic
; pm.max_children = 50
; pm.start_servers = 10
; pm.min_spare_servers = 5
; pm.max_spare_servers = 15
; pm.max_requests = 500

; Kill workers after 500 requests to prevent memory leaks
pm.max_requests = 500

; Slow log to catch problematic PHP scripts
request_slowlog_timeout = 5s
slowlog = /var/log/php-fpm/slow.log

; Catch workers that segfault
catch_workers_output = yes

Use pm = static for dedicated WordPress servers. Dynamic process management adds overhead from constant forking. Static pools keep processes ready and eliminate fork latency. Calculate pm.max_children by dividing available memory by average worker memory usage:

# Check average worker memory usage
ps -ylC php-fpm8.2 --sort:rss

# The RSS column shows resident memory per worker in KB
# Formula: (Total_RAM - Reserved_for_OS_and_MySQL) / Avg_Worker_RAM

Enable MySQL Slow Query Logging

Add to /etc/mysql/mysql.conf.d/mysqld.cnf:

slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 2
log_queries_not_using_indexes = 1

After restarting MySQL, analyze the slow log with mysqldumpslow:

# Show top 10 slowest queries
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log

# Show queries that appear most frequently
mysqldumpslow -s c -t 10 /var/log/mysql/slow.log

Queries logging to this file that come from WordPress typically indicate missing indexes, inefficient meta queries, or plugins doing unfiltered SELECT * on large tables. Address every query that appears here β€” each one represents users waiting.

Database Index Optimization

WordPress creates indexes on core tables but they are designed for general use, not your specific query patterns. Add targeted indexes for your workload:

-- For sites with heavy meta queries on a specific key
ALTER TABLE wp_postmeta ADD INDEX meta_key_value (meta_key(191), meta_value(100));

-- For admin queries filtering by post type and status
ALTER TABLE wp_posts ADD INDEX type_status_date (post_type, post_status, post_date);

-- For comment-heavy sites querying by comment_approved and comment_type
ALTER TABLE wp_comments ADD INDEX comment_approved_type (comment_approved, comment_type);

-- Check for unused indexes (MySQL 8.0+)
SELECT * FROM sys.schema_unused_indexes WHERE object_schema = 'your_database';

After adding indexes, verify they are being used:

EXPLAIN SELECT * FROM wp_postmeta WHERE meta_key = '_edit_lock';
# Look for "type: ref" and "Extra: Using index" in the output

Blackfire Profiling Setup

Blackfire.io provides function-level profiling that Pinpoints exactly where time and memory are spent. Install the probe:

# Install Blackfire probe
wget -q -O - https://packages.blackfire.io/gpg.key | sudo apt-key add -
echo "deb http://packages.blackfire.io/debian any main" | sudo tee /etc/apt/sources.list.d/blackfire.list
sudo apt-get update
sudo apt-get install blackfire-php blackfire-agent

# Configure the agent with your server credentials
sudo blackfire-agent -register
# Enter your Server ID and Server Token from the Blackfire dashboard

# Restart PHP-FPM
sudo systemctl restart php8.2-fpm

Profile a request from the CLI:

blackfire curl https://example.com/

Or use the Chrome extension for browser-based profiling. Focus on the "Wall Time" and "I/O Wait" metrics. In WordPress, the biggest wins typically come from:

  • Eliminating redundant get_option() calls β€” use object caching
  • Replacing WP_Query with cached transients for expensive queries
  • Removing actions and filters that fire on every request but serve no purpose

Measuring Before and After

Every optimization must be measured. Use these tools in sequence:

  1. Apache Bench for throughput: ab -n 1000 -c 50 https://example.com/ β€” check requests/second before and after.
  2. Query Monitor plugin: Count database queries and total query time on every page load.
  3. PHP-FPM status page: Enable pm.status_path = /status in the pool config and query it for active processes, queue length, and slow requests.
  4. Blackfire: For detailed function-level analysis when the above tools show a problem but you cannot identify the cause.

Apply one optimization at a time and measure. Stacking multiple changes at once makes it impossible to identify which change delivered the improvement. Document every change and its measured impact in your deployment log.

Leave a Reply

Your email address will not be published. Required fields are marked *