How to Increase Web Server Speed in PHP-The Complete Production Optimization Guide

1391 views
How to Increase Web Server Speed in PHP: A Complete Guide

If your website is built on PHP (WordPress, Laravel, or custom apps), speed matters more than ever. A slow web server impacts SEO rankings, user experience, and conversions.

This guide covers proven ways to optimize PHP web server performance using PHP configurations, caching, and server tuning.

Why Optimizing PHP Performance is Important

PHP powers over 75% of websites. But default settings aren’t always optimized for performance.
A well-tuned PHP server can:

  • Load pages faster
  • Handle more traffic without crashing
  • Improve Google SEO ranking
  • Reduce hosting costs

Step 1: Upgrade & Optimize PHP Configuration

Always start with the basics — your PHP version and configuration.

Upgrade PHP
Use PHP 8.2 or PHP 8.3 — much faster than older versions.

Enable OPcache
Add this in php.ini:

 
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=10000
opcache.revalidate_freq=0

Increase PHP Memory

 
memory_limit = 256M

Step 2: Use Nginx or LiteSpeed with PHP-FPM

Your web server stack makes a huge difference.

  • Nginx or LiteSpeed are faster than Apache.
  • PHP-FPM (FastCGI Process Manager) reduces load time.

Example config:

 
pm = dynamic
pm.max_children = 50
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 20

Step 3: Implement Caching

Caching avoids regenerating the same PHP output repeatedly.

  • Opcode cache → OPcache (done above).
  • Page caching → W3 Total Cache, LiteSpeed Cache, WP Super Cache (WordPress).
  • Object caching → Redis or Memcached.

Step 4: Optimize the Database

Since PHP apps rely on databases, optimize them too.

  • Add proper indexes.
  • Remove unused tables.
  • Run OPTIMIZE TABLE.
  • Consider MariaDB or MySQL 8.

Step 5: Frontend & Delivery Tweaks

  • Enable GZIP/Brotli compression.
  • Use HTTP/2 or HTTP/3 (QUIC).
  • Add a CDN (Cloudflare, BunnyCDN, etc.).
  • Lazy-load images & minify CSS/JS.

Step 6: Write Efficient PHP Code

Bad code slows everything down.

  • Avoid unnecessary loops.
  • Use prepared SQL statements.
  • Cache API calls.
  • Keep plugins and themes updated.

Step 7: Monitor & Profile

You can’t optimize what you don’t measure.

  • Xdebug / Blackfire → profile PHP code.
  • New Relic → monitor server load.
  • GTmetrix / Lighthouse → test speed.

Step 8: Security & Stability

Performance also depends on stability.

  • Update PHP, plugins, and themes.
  • Remove unused extensions.
  • Use a WAF + backups.

To increase web server speed in PHP, focus on:

  1. Latest PHP + OPcache
  2. PHP-FPM with Nginx or LiteSpeed
  3. Caching (Page, Object, CDN)
  4. Database tuning
  5. Code efficiency

A faster site = happier visitors, higher SEO rankings, and better business results.

 

Why “Use OPcache” Is Where Optimization Begins, Not Ends

PHP performance optimization is not a checklist. It’s a layered discipline where each improvement compounds on the ones below it. The original article lists eight steps and 300 words — the equivalent of telling someone to “eat well and exercise” as a complete fitness plan.

Real PHP performance work looks like this: a 400ms average response time becomes 180ms after OPcache tuning, drops to 90ms after PHP-FPM process manager optimisation, falls to 45ms after Redis object caching, and hits 12ms after full-page caching — a 97% reduction in TTFB without touching a single line of application code.

Then application-level work begins: query optimisation, N+1 detection, payload compression, connection pooling — each shaving off more latency.

This guide is the complete version of that journey, with:

  • Measurable benchmarks at each stage
  • Production configuration values (not “256M” with no explanation of why)
  • Environment-specific guidance (shared hosting vs VPS vs Docker vs cloud)
  • PHP 8 JIT and preloading (completely absent from the original)
  • Profiling workflow to find actual bottlenecks before guessing
  • Real code patterns that affect performance
  • WordPress, Laravel, and raw PHP contexts throughout

Part 0 — Measure Before You Optimise: The Profiling Workflow

Never optimise blindly. Every performance fix should be preceded by a measurement and followed by a verification. Without this, you risk spending hours on the wrong bottleneck.

Step 1: Establish a Baseline

# Install Apache Bench (ab) if not present:
sudo apt install apache2-utils

# Baseline test: 1000 requests, 20 concurrent
ab -n 1000 -c 20 -g baseline.tsv https://yoursite.com/

# Key metrics to record:
# Requests per second:    N.NN [#/sec]
# Time per request:       NNN.NNN [ms] (mean)
# Time per request:       NN.NN [ms] (mean, across concurrent)
# Failed requests:        0 (any non-zero = problem)

# Alternatively use wrk (more realistic):
wrk -t4 -c100 -d30s https://yoursite.com/

# Or httpd-tools for distributed load:
ab -n 5000 -c 50 -k https://yoursite.com/

Step 2: Profile Individual PHP Scripts

# Install Xdebug (CLI profiling — minimal overhead)
sudo apt install php8.3-xdebug

# /etc/php/8.3/cli/conf.d/20-xdebug.ini:
zend_extension=xdebug.so
xdebug.mode=profile
xdebug.output_dir=/tmp/xdebug_profiles
xdebug.profiler_output_name=cachegrind.out.%p.%H

# Run your script once:
php -d xdebug.mode=profile slow_script.php

# Analyse with kcachegrind (Linux) or qcachegrind (Mac/Windows):
kcachegrind /tmp/xdebug_profiles/cachegrind.out.*

Step 3: Production Profiling with Tideways/Blackfire

# Blackfire (best SaaS profiler for PHP):
curl -s https://packagecloud.io/gpg.key | sudo apt-key add -
echo "deb https://packages.blackfire.io/debian any main" > /etc/apt/sources.list.d/blackfire.list
sudo apt update && sudo apt install blackfire

# Profile a single HTTP request:
blackfire curl https://yoursite.com/your-slow-page

# Profile CLI script:
blackfire run php artisan heavy:command

# Profile results show:
# - Wall time per function call
# - Memory allocations per function
# - I/O time breakdown
# - Call graph with hotspots highlighted

Step 4: Identify the Real Bottleneck Type

Response Time Breakdown (what's actually slow):
─────────────────────────────────────────────────────────────────────
PHP execution time:    → OPcache, JIT, code quality, preloading
Database queries:      → Query optimisation, indexing, connection pooling
Network / I/O:         → CDN, async external calls, response compression
Web server overhead:   → PHP-FPM tuning, Nginx worker config
Memory allocation:     → Object reuse, PSR-6 caching, GC tuning
File system:           → Preloading, tmpfs, realpath_cache
─────────────────────────────────────────────────────────────────────

The PHP Performance Diagnostic Script

<?php
/**
 * php_performance_check.php
 * Run: php php_performance_check.php
 * Identifies the most impactful optimisations available on the current server.
 */

$checks = [];

// ── OPcache ──────────────────────────────────────────────────────────────────
$opcache = function_exists('opcache_get_status') ? opcache_get_status(false) : false;
$checks['opcache_enabled'] = [
    'status' => extension_loaded('Zend OPcache') && ($opcache['opcache_enabled'] ?? false),
    'label'  => 'OPcache enabled',
    'impact' => 'HIGH — 2-5× PHP execution speed improvement',
    'fix'    => 'opcache.enable=1 in php.ini',
];

$checks['opcache_memory'] = [
    'status' => (int)ini_get('opcache.memory_consumption') >= 128,
    'label'  => 'OPcache memory ≥ 128MB',
    'impact' => 'HIGH — prevents cache evictions under load',
    'fix'    => 'opcache.memory_consumption=256',
    'current'=> ini_get('opcache.memory_consumption') . 'MB',
];

// ── JIT ──────────────────────────────────────────────────────────────────────
$jit_status = $opcache['jit']['enabled'] ?? false;
$checks['jit'] = [
    'status' => $jit_status,
    'label'  => 'JIT Compiler enabled (PHP 8.0+)',
    'impact' => 'MEDIUM — 10-30% for CPU-bound code',
    'fix'    => 'opcache.jit_buffer_size=256M + opcache.jit=tracing',
];

// ── PHP-FPM ──────────────────────────────────────────────────────────────────
$checks['php_fpm'] = [
    'status' => PHP_SAPI === 'fpm-fcgi',
    'label'  => 'Running under PHP-FPM',
    'impact' => 'HIGH — much lower memory usage than mod_php',
    'fix'    => 'Switch from mod_php to PHP-FPM + Nginx',
];

// ── Preloading ───────────────────────────────────────────────────────────────
$preload = ini_get('opcache.preload');
$checks['preloading'] = [
    'status' => !empty($preload) && file_exists($preload),
    'label'  => 'OPcache Preloading configured (PHP 7.4+)',
    'impact' => 'MEDIUM — eliminates file loading on first request',
    'fix'    => 'opcache.preload=/path/to/preload.php',
    'current'=> $preload ?: 'Not configured',
];

// ── Realpath cache ───────────────────────────────────────────────────────────
$realpath = ini_get('realpath_cache_size');
$checks['realpath_cache'] = [
    'status' => $realpath && intval($realpath) >= 4096,
    'label'  => 'Realpath cache ≥ 4MB',
    'impact' => 'MEDIUM — eliminates filesystem stat() calls',
    'fix'    => 'realpath_cache_size=4096K + realpath_cache_ttl=600',
    'current'=> $realpath,
];

// ── Memory limit ─────────────────────────────────────────────────────────────
$memory = ini_get('memory_limit');
$memoryMB = intval($memory);
$checks['memory'] = [
    'status' => $memoryMB >= 128 && $memoryMB <= 512,
    'label'  => 'Memory limit 128–512MB (not unlimited)',
    'impact' => 'MEDIUM — too low causes crashes, too high hides leaks',
    'fix'    => 'memory_limit=256M for typical apps, 512M for heavy ones',
    'current'=> $memory,
];

// ── Extensions ───────────────────────────────────────────────────────────────
foreach (['redis','memcached','apcu'] as $ext) {
    $checks["ext_{$ext}"] = [
        'status' => extension_loaded($ext),
        'label'  => "{$ext} extension available",
        'impact' => 'HIGH — enables fast in-memory caching',
        'fix'    => "sudo apt install php8.3-{$ext}",
    ];
}

// ── Output ───────────────────────────────────────────────────────────────────
echo str_repeat('═', 60) . "\n";
echo "PHP Performance Diagnostic — " . PHP_VERSION . "\n";
echo "SAPI: " . PHP_SAPI . " | OS: " . PHP_OS . "\n";
echo str_repeat('═', 60) . "\n\n";

foreach ($checks as $check) {
    $icon = $check['status'] ? '✅' : '❌';
    echo "{$icon} {$check['label']}\n";
    if (!$check['status']) {
        echo "   Impact:  {$check['impact']}\n";
        echo "   Fix:     {$check['fix']}\n";
    }
    if (isset($check['current'])) {
        echo "   Current: {$check['current']}\n";
    }
    echo "\n";
}

// OPcache stats if available
if ($opcache) {
    $stats    = $opcache['opcache_statistics'];
    $hitRate  = round($stats['opcache_hit_rate'], 2);
    $memory   = $opcache['memory_usage'];
    $usedMB   = round($memory['used_memory'] / 1024 / 1024, 1);
    $freeMB   = round($memory['free_memory'] / 1024 / 1024, 1);

    echo "OPcache Statistics:\n";
    echo "  Hit rate:     {$hitRate}%  (target: >95%)\n";
    echo "  Memory used:  {$usedMB}MB / " . ($usedMB + $freeMB) . "MB\n";
    echo "  Cached files: {$stats['num_cached_scripts']}\n";
    echo "  Restarts:     " . ($stats['oom_restarts'] > 0 ? "⚠️ {$stats['oom_restarts']} OOM restarts — increase memory!" : "0 ✅") . "\n";
}

 

Part 1 — PHP Version and OPcache: The Mandatory Foundation

PHP Version Performance Benchmarks (Real Numbers)

PHP Version Performance Comparison (WordPress admin page load, ab -n 500 -c 10):
─────────────────────────────────────────────────────────────────────────────────
PHP 7.0:   ~45 req/sec  (baseline)
PHP 7.4:   ~78 req/sec  (+73%)
PHP 8.0:   ~88 req/sec  (+96%)  — JIT introduced
PHP 8.1:   ~99 req/sec  (+120%) — Fibers, readonly properties
PHP 8.2:   ~103 req/sec (+129%) — readonly classes, performance improvements
PHP 8.3:   ~108 req/sec (+140%) — typed class constants, json_validate()
PHP 8.4:   ~114 req/sec (+153%) — property hooks, asymmetric visibility
─────────────────────────────────────────────────────────────────────────────────
Source: Internal benchmarks, multiple public PHP benchmarks corroborate this range.
Note: Exact numbers vary by workload. CPU-bound code sees larger JIT improvements.

Upgrade PHP on Ubuntu/Debian

# Add Ondřej Surý's PHP PPA (most reliable PHP PPA for Ubuntu):
sudo add-apt-repository ppa:ondrej/php -y
sudo apt update

# Install PHP 8.3 (or latest available):
sudo apt install php8.3 php8.3-fpm php8.3-cli php8.3-common \
    php8.3-mysql php8.3-pgsql php8.3-redis php8.3-memcached \
    php8.3-curl php8.3-gd php8.3-imagick php8.3-mbstring \
    php8.3-xml php8.3-zip php8.3-bcmath php8.3-intl \
    php8.3-opcache php8.3-apcu

# Set as default for CLI:
sudo update-alternatives --set php /usr/bin/php8.3

# Set for Apache (if using mod_php):
sudo a2dismod php8.2
sudo a2enmod php8.3
sudo systemctl restart apache2

# Verify:
php --version

Production OPcache Configuration (The Right Values, Not Just “Enable It”)

The original article gives opcache.memory_consumption=256 with zero explanation. Here’s the complete, commented configuration:

; /etc/php/8.3/fpm/conf.d/10-opcache.ini
; Also apply to /etc/php/8.3/cli/conf.d/10-opcache.ini

[opcache]
; ── Core settings ──────────────────────────────────────────────────────────

; Enable OPcache (always do this first)
opcache.enable=1

; Enable for CLI scripts too (important for Artisan, WP-CLI, cron)
opcache.enable_cli=1

; ── Memory settings ────────────────────────────────────────────────────────

; Shared memory for compiled PHP bytecode
; Small site (< 100 PHP files):    64M
; Medium site (100–500 files):     128M
; Large site/CMS (500+ files):     256M
; Enterprise / 2000+ files:        512M
; WordPress with plugins:          256M minimum
opcache.memory_consumption=256

; Memory for interned strings (function names, variable names, class names)
; Reduces memory per-process by storing strings once
; Default 8M is too low for large apps — use 16-64M
opcache.interned_strings_buffer=64

; ── File caching ───────────────────────────────────────────────────────────

; Maximum number of PHP files to cache
; Run: find /var/www -name "*.php" | wc -l  to count your files
; Set to ~1.5× your file count, must be a prime number for hash table efficiency
; Common primes: 3907, 7963, 16231, 32521, 65537
opcache.max_accelerated_files=32521

; How often (in seconds) OPcache checks if files have changed
; Production: 0 (never check — requires manual cache clear after deploy)
; Development: 1-2 (check on every request or every 2 seconds)
opcache.revalidate_freq=0

; Validate file existence on each request (small overhead, prevents stale cache)
; Set to 0 on production for maximum performance
opcache.validate_timestamps=0

; When validate_timestamps=0, use this to clear cache after deployment:
; php -r "opcache_reset();" OR killall -USR2 php-fpm8.3

; ── JIT (PHP 8.0+) ────────────────────────────────────────────────────────

; JIT buffer size (shared memory for JIT-compiled machine code)
; 0 = disabled
; Recommended: 64M for small apps, 256M for large apps
; Note: JIT benefits CPU-bound code most (math, data transformation)
; Pure I/O-bound code (database, API calls) sees minimal JIT improvement
opcache.jit_buffer_size=256M

; JIT mode:
; 0 = disabled
; 1 = tracing (best for typical web apps — traces hot paths)
; 2 = function (compiles entire functions — better for pure computation)
; Recommended: 1254 (tracing + optimisation level 4)
opcache.jit=tracing

; ── Preloading (PHP 7.4+) ─────────────────────────────────────────────────

; Path to preload script (run once at FPM startup, loads files into shared memory)
; Leave empty if not configured yet (see Part 3)
; opcache.preload=/var/www/html/preload.php
; opcache.preload_user=www-data  ; Required for security (can't run as root)

; ── Performance settings ───────────────────────────────────────────────────

; Number of save comments in bytecode (set 0 to save memory, 1 for tools)
opcache.save_comments=1  ; Keep 1 for Doctrine/Laravel annotations

; Enable fast shutdown (faster request shutdown, trade-off: slightly less safe on crash)
opcache.fast_shutdown=0  ; Keep 0 for stability

; ── Monitoring ─────────────────────────────────────────────────────────────

; Enable statistics (slight overhead, useful during tuning)
opcache.enable_statistics=1

; Error log
opcache.error_log=/var/log/php/opcache.log

Verify OPcache Is Working

<?php
// opcache_status.php — run this to see OPcache performance
// IMPORTANT: Protect this file — it exposes server internals

$status = opcache_get_status();
$config = opcache_get_configuration();

$hitRate    = round($status['opcache_statistics']['opcache_hit_rate'], 2);
$usedMB     = round($status['memory_usage']['used_memory'] / 1048576, 1);
$totalMB    = ini_get('opcache.memory_consumption');
$jitEnabled = $status['jit']['enabled'] ?? false;
$cachedFiles= $status['opcache_statistics']['num_cached_scripts'];
$oomRestarts= $status['opcache_statistics']['oom_restarts'];

echo "OPcache Status Report\n";
echo "═══════════════════════════\n";
echo "Enabled:       " . ($status['opcache_enabled'] ? '✅ Yes' : '❌ No') . "\n";
echo "Hit Rate:      {$hitRate}% " . ($hitRate > 95 ? '✅' : ($hitRate > 85 ? '⚠️' : '❌')) . "\n";
echo "Memory Used:   {$usedMB}MB / {$totalMB}MB\n";
echo "Cached Files:  {$cachedFiles}\n";
echo "JIT:           " . ($jitEnabled ? '✅ Active' : '❌ Disabled') . "\n";
echo "OOM Restarts:  " . ($oomRestarts > 0 ? "⚠️ {$oomRestarts} — increase memory!" : '0 ✅') . "\n";

// Target values:
// Hit rate > 95% = good
// Memory used < 80% = good
// OOM restarts = 0 = essential

 

Part 2 — PHP-FPM: The Most Impactful Single Configuration Change

PHP-FPM (FastCGI Process Manager) is what controls how many PHP worker processes handle requests simultaneously. Getting this configuration wrong is the #1 cause of PHP servers that either idle with wasted RAM or collapse under moderate load.

Understanding PHP-FPM Process Managers

Process Manager Modes:
─────────────────────────────────────────────────────────────────────────
static:   Fixed number of workers — always running, predictable memory
           Best for: High-traffic sites with consistent load
           Risk: Always consumes maximum RAM even at low traffic

dynamic:  Workers scale between min/max based on demand
           Best for: Variable traffic (most websites)
           Risk: Slower to scale up if traffic spikes suddenly

ondemand: Workers created on demand, shut down when idle
           Best for: Low-traffic sites or development
           Risk: Slow first-request latency after idle period
─────────────────────────────────────────────────────────────────────────

Calculate the Right pm.max_children Value

This is the most critical PHP-FPM setting and it depends on your specific server.

# Step 1: Find your average PHP-FPM worker memory usage
ps aux | grep 'php-fpm' | grep -v grep | awk '{print $6}' | sort -rn | head -20

# Average of those numbers (in KB) = average worker memory
# Typical range: 30MB–80MB for WordPress, 20MB–50MB for Laravel API

# Step 2: Calculate safe max_children
# Formula: (Available RAM for PHP) / (Average worker memory)
#
# Example: 2GB VPS, 512MB for OS/Nginx/MySQL = 1.5GB for PHP
# Average worker = 60MB
# Safe max_children = 1500MB / 60MB = 25
#
# With some safety margin: set to 20-22

# Step 3: Verify available RAM:
free -m
# Look at "available" column (not "free")

 

Production PHP-FPM Configuration

; /etc/php/8.3/fpm/pool.d/www.conf
; Complete annotated production configuration

[www]
; The socket PHP-FPM listens on (Unix socket is faster than TCP for same-server)
listen = /run/php/php8.3-fpm.sock

; Socket ownership (must match Nginx worker user)
listen.owner = www-data
listen.group = www-data
listen.mode  = 0660

; PHP-FPM process user
user  = www-data
group = www-data

; ── Process Manager ─────────────────────────────────────────────────────────

pm = dynamic

; Maximum concurrent PHP workers (CRITICAL — set based on RAM calculation above)
; Formula: available_RAM_MB / avg_worker_MB
; Conservative: use 75% of calculated max for safety margin
pm.max_children = 25

; Workers to start when PHP-FPM starts
; Recommendation: 25% of max_children
pm.start_servers = 6

; Minimum idle workers to keep ready (never kill below this)
; Recommendation: 25% of max_children
pm.min_spare_servers = 4

; Maximum idle workers to keep (kill above this during low traffic)
; Recommendation: 50% of max_children
pm.max_spare_servers = 12

; Requests per worker before recycling (prevents memory leaks in long-running sites)
; 500 for stable apps, 200 for apps with suspected memory leaks
pm.max_requests = 500

; ── Timeouts ────────────────────────────────────────────────────────────────

; Kill PHP process if it takes longer than this (prevents hung processes)
; Typical value: 60-120 seconds
; For async/queue workers: 300-3600 seconds
request_terminate_timeout = 120s

; ── Status and Monitoring ───────────────────────────────────────────────────

; Enable /php-fpm-status endpoint for monitoring (restrict access in Nginx!)
pm.status_path = /php-fpm-status

; Slow log — log requests taking longer than X seconds
slowlog = /var/log/php/php8.3-fpm.slow.log
request_slowlog_timeout = 5s
request_slowlog_trace_depth = 20

; ── PHP Settings per Pool ──────────────────────────────────────────────────
; These override php.ini for this pool only

php_admin_value[error_log] = /var/log/php/php8.3-fpm-error.log
php_admin_flag[log_errors] = on

; Per-pool PHP settings (useful for different apps needing different configs)
; php_admin_value[memory_limit] = 256M
; php_admin_value[upload_max_filesize] = 32M
; php_admin_value[post_max_size] = 33M

; ── Environment Variables ──────────────────────────────────────────────────
env[PATH] = /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
env[TMPDIR] = /tmp

 

Monitor PHP-FPM in Real Time

# Real-time FPM status:
curl http://localhost/php-fpm-status?full

# Key metrics to watch:
# listen queue: 0 = good, > 0 = workers saturated (increase max_children)
# active processes: approaching max_children = capacity issue
# max children reached: count > 0 = you've hit the limit under load

# Automated monitoring script:
#!/bin/bash
# /usr/local/bin/fpm_monitor.sh
STATUS=$(curl -s "http://localhost/php-fpm-status")
ACTIVE=$(echo "$STATUS" | grep "active processes" | awk '{print $3}')
MAX=$(echo "$STATUS" | grep "max children reached" | awk '{print $4}')
QUEUE=$(echo "$STATUS" | grep "listen queue:" | awk '{print $3}')

echo "[$(date)] Active: $ACTIVE | Queue: $QUEUE | Max reached: $MAX"

if [ "$QUEUE" -gt 0 ]; then
    echo "WARNING: Request queue not empty ($QUEUE). Increase pm.max_children!"
fi

 

Part 3 — OPcache Preloading (PHP 7.4+ — Massively Underused)

Preloading compiles and loads PHP files into shared memory at PHP-FPM startup. Once preloaded, files are available to ALL worker processes without any per-request file loading overhead. This is particularly powerful for frameworks and CMSs where the same core files are loaded on every request.

How Much Does Preloading Help?

Framework first-request time with warm OPcache:
──────────────────────────────────────────────────────────────────────
                    Without Preload   With Preload   Improvement
Laravel (full)          45ms            28ms           -38%
Symfony (full)          38ms            22ms           -42%
WordPress (admin)       80ms            55ms           -31%
Raw PHP (heavy OOP)     12ms             8ms           -33%
──────────────────────────────────────────────────────────────────────
These numbers are for the PHP execution portion only, not full TTFB.

Laravel Preload Script

<?php
// /var/www/yourapp/preload.php
// Loaded once at PHP-FPM startup — shared across all workers

// Preload Composer's autoloader
require_once '/var/www/yourapp/vendor/autoload.php';

// Preload Laravel framework core files
$preloadPaths = [
    '/var/www/yourapp/vendor/laravel/framework/src/Illuminate',
    '/var/www/yourapp/vendor/symfony/http-foundation',
    '/var/www/yourapp/vendor/symfony/http-kernel',
    '/var/www/yourapp/vendor/nesbot/carbon/src',
];

$preloaded = 0;

foreach ($preloadPaths as $path) {
    if (!is_dir($path)) continue;

    $iterator = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS)
    );

    foreach ($iterator as $file) {
        if ($file->getExtension() !== 'php') continue;
        if (str_contains($file->getPathname(), '/Test')) continue;    // Skip tests
        if (str_contains($file->getPathname(), '/tests')) continue;

        if (opcache_compile_file($file->getPathname())) {
            $preloaded++;
        }
    }
}

// Log preloaded file count
error_log("OPcache Preloading: {$preloaded} files compiled into shared memory.");

WordPress Preload Script

<?php
// /var/www/wordpress/preload.php

$wp_core_files = [
    '/var/www/wordpress/wp-includes/class-wp.php',
    '/var/www/wordpress/wp-includes/class-wp-hook.php',
    '/var/www/wordpress/wp-includes/class-wp-query.php',
    '/var/www/wordpress/wp-includes/class-wp-post.php',
    '/var/www/wordpress/wp-includes/class-wp-user.php',
    '/var/www/wordpress/wp-includes/functions.php',
    '/var/www/wordpress/wp-includes/post.php',
    '/var/www/wordpress/wp-includes/taxonomy.php',
    '/var/www/wordpress/wp-includes/meta.php',
    '/var/www/wordpress/wp-includes/formatting.php',
    '/var/www/wordpress/wp-includes/cache.php',
    '/var/www/wordpress/wp-includes/pluggable.php',
    '/var/www/wordpress/wp-includes/option.php',
];

$preloaded = 0;

foreach ($wp_core_files as $file) {
    if (file_exists($file) && opcache_compile_file($file)) {
        $preloaded++;
    }
}

// Also preload active plugin files (adjust paths as needed):
$plugin_files = glob('/var/www/wordpress/wp-content/plugins/woocommerce/includes/*.php') ?: [];
foreach ($plugin_files as $file) {
    if (opcache_compile_file($file)) {
        $preloaded++;
    }
}

error_log("WordPress Preloading: {$preloaded} files compiled.");

Enable Preloading in php.ini

; /etc/php/8.3/fpm/php.ini
[opcache]
opcache.preload = /var/www/yourapp/preload.php
; REQUIRED: Can't run preload as root
opcache.preload_user = www-data
# Apply and restart:
sudo systemctl restart php8.3-fpm

# Verify preloading worked:
php-fpm8.3 -t   # Test config
# Check error log for: "OPcache Preloading: N files compiled into shared memory."

 

Part 4 — The JIT Compiler: What It Actually Does and When It Helps

PHP 8.0 introduced a Just-In-Time (JIT) compiler. It’s the most-hyped PHP 8 feature and also the most misunderstood. Here’s what the original article doesn’t tell you:

JIT Helps These Workloads

High JIT benefit (20–50% speedup):
  ✅ Mathematical calculations (statistics, finance, simulation)
  ✅ Image processing (GD operations, colour manipulation)
  ✅ Data transformation (CSV parsing, JSON processing, serialisation)
  ✅ Cryptographic operations
  ✅ Pure-PHP compute tasks (sorting algorithms, data structures)

Low or no JIT benefit (0–10% speedup):
  ❌ Database-heavy apps (most time is spent waiting for MySQL)
  ❌ API consumers (most time is spent waiting for external HTTP)
  ❌ File I/O heavy apps (most time is spent in file system operations)
  ❌ WordPress / typical CMSs (mostly I/O and database bound)
  ❌ REST APIs with simple CRUD operations

JIT Configuration Options

; In php.ini [opcache] section:

; JIT must be enabled (OPcache must also be enabled):
opcache.jit_buffer_size=256M

; JIT mode string: CRTO
; C = CPU-specific optimisations (0=off, 1=AVX, 2=SSE4.2...)
; R = Register allocation (0=none, 1=local, 2=global)
; T = Trigger (0=off, 1=on script load, 2=on hot function, 3=on hot trace, 4=startup)
; O = Optimisation level (0=none, 1=minimal, 2=selective, 3=full, 4=max+unsafe)

; Common choices:
; opcache.jit=0          → Disable JIT
; opcache.jit=1205       → Function JIT, moderate (safe default)
; opcache.jit=tracing    → Best for web apps (equivalent to 1254)
; opcache.jit=function   → Best for CLI/compute tasks (equivalent to 1205)
opcache.jit=tracing

Benchmark JIT On Your Specific Workload

<?php
// jit_benchmark.php
// Run with JIT on and off to measure actual benefit

$iterations = 1_000_000;

// Test 1: Mathematical computation (JIT helps this)
$start = hrtime(true);
$sum = 0;
for ($i = 0; $i < $iterations; $i++) {
    $sum += sqrt($i) * log($i + 1);
}
$mathMs = (hrtime(true) - $start) / 1e6;

// Test 2: String operations (JIT helps less)
$start = hrtime(true);
$str = '';
for ($i = 0; $i < 100000; $i++) {
    $str = str_pad((string)$i, 10, '0', STR_PAD_LEFT);
}
$stringMs = (hrtime(true) - $start) / 1e6;

// Test 3: Array operations (JIT moderate benefit)
$start = hrtime(true);
$arr = range(1, 10000);
for ($i = 0; $i < 100; $i++) {
    usort($arr, fn($a, $b) => $b <=> $a);
}
$arrayMs = (hrtime(true) - $start) / 1e6;

$jitStatus = opcache_get_status()['jit']['enabled'] ?? false;

echo "JIT: " . ($jitStatus ? 'ON' : 'OFF') . " | PHP " . PHP_VERSION . "\n";
echo "Math ops:    {$mathMs}ms\n";
echo "String ops:  {$stringMs}ms\n";
echo "Array ops:   {$arrayMs}ms\n";

// Run twice: once with JIT (current config) and once with opcache.jit=0 to compare

 

Part 5 — Nginx Configuration for PHP Performance

The original article says “use Nginx” but gives no Nginx configuration. Here’s the complete, production-tuned Nginx config for PHP applications.

Core Nginx Worker Configuration

# /etc/nginx/nginx.conf

user www-data;

# Set to number of CPU cores (auto = detect automatically)
worker_processes auto;
worker_cpu_affinity auto;

# Maximum file descriptors per worker
worker_rlimit_nofile 65535;

events {
    # Maximum simultaneous connections per worker
    # Total max connections = worker_processes × worker_connections
    worker_connections 4096;

    # Allow multiple connections per accept() call (better throughput)
    multi_accept on;

    # Event processing model (epoll is best on Linux)
    use epoll;
}

http {
    # ── Basic Settings ───────────────────────────────────────────────────────

    sendfile    on;    # Zero-copy file transfer (critical for static files)
    tcp_nopush  on;    # Batch TCP headers (works with sendfile)
    tcp_nodelay on;    # Disable Nagle algorithm for keep-alive connections

    # ── Timeouts ────────────────────────────────────────────────────────────

    keepalive_timeout       65;     # How long to keep idle connections alive
    keepalive_requests      1000;   # Max requests per keep-alive connection
    client_header_timeout   15;     # Time to read complete request header
    client_body_timeout     15;     # Time between consecutive reads of client body
    reset_timedout_connection on;   # Drop timed-out connections immediately
    send_timeout            15;     # Time between writes to client

    # ── Buffer Tuning ───────────────────────────────────────────────────────

    client_body_buffer_size     128k;   # Temp file threshold for request body
    client_max_body_size         50m;   # Maximum upload size (match PHP upload_max_filesize)
    client_header_buffer_size   1k;     # Buffer for reading request headers
    large_client_header_buffers 4 16k;  # For large cookies / OAuth tokens

    # ── Compression (Gzip) ──────────────────────────────────────────────────

    gzip on;
    gzip_comp_level    6;       # Compression level 1-9 (6 = best ratio/CPU balance)
    gzip_min_length    1024;    # Don't compress files smaller than 1KB
    gzip_vary          on;      # Add Vary: Accept-Encoding header
    gzip_proxied       any;     # Compress for all proxy types
    gzip_types
        text/plain
        text/css
        text/javascript
        application/javascript
        application/json
        application/xml
        image/svg+xml
        font/woff2;

    # ── Brotli (better than gzip — requires ngx_brotli module) ──────────────
    # brotli on;
    # brotli_comp_level 6;
    # brotli_types text/plain text/css application/json application/javascript;

    # ── Cache Static Files ──────────────────────────────────────────────────

    open_file_cache         max=2000 inactive=20s;  # Cache file descriptors
    open_file_cache_valid   30s;                     # How often to revalidate
    open_file_cache_min_uses 2;                      # Cache after 2 accesses
    open_file_cache_errors  on;                      # Cache not-found results too

    # ── FastCGI Cache (full-page PHP output caching) ─────────────────────────

    fastcgi_cache_path /tmp/nginx_cache levels=1:2
                       keys_zone=php_cache:100m
                       max_size=1g
                       inactive=60m
                       use_temp_path=off;
    fastcgi_cache_key "$scheme$request_method$host$request_uri";

    # ── Logging ─────────────────────────────────────────────────────────────

    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" $request_time';

    access_log /var/log/nginx/access.log main buffer=64k flush=5s;
    error_log  /var/log/nginx/error.log warn;

    include /etc/nginx/conf.d/*.conf;
    include /etc/nginx/sites-enabled/*;
}

PHP Application Server Block

# /etc/nginx/sites-available/myapp.conf

server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name example.com www.example.com;

    root /var/www/html;
    index index.php;

    # ── SSL ─────────────────────────────────────────────────────────────────

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256;
    ssl_prefer_server_ciphers off;
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_stapling        on;
    ssl_stapling_verify on;

    # HSTS (enable after confirming HTTPS works)
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

    # ── Security Headers ─────────────────────────────────────────────────────

    add_header X-Content-Type-Options   "nosniff"       always;
    add_header X-Frame-Options          "SAMEORIGIN"    always;
    add_header X-XSS-Protection         "1; mode=block" always;
    add_header Referrer-Policy          "strict-origin-when-cross-origin" always;

    # ── Static File Caching ──────────────────────────────────────────────────

    location ~* \.(css|js|jpg|jpeg|png|gif|webp|avif|svg|ico|woff2|woff|ttf|eot)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    # ── Deny Hidden Files ────────────────────────────────────────────────────

    location ~ /\. { deny all; }
    location ~ ~$ { deny all; }

    # ── FastCGI Cache Configuration ──────────────────────────────────────────

    set $skip_cache 0;

    # Don't cache POST requests
    if ($request_method = POST) { set $skip_cache 1; }

    # Don't cache logged-in users (WordPress example)
    if ($http_cookie ~* "wordpress_logged_in|woocommerce_cart|comment_author") {
        set $skip_cache 1;
    }

    # Don't cache admin area
    if ($request_uri ~* "^/wp-admin|wp-login\.php") {
        set $skip_cache 1;
    }

    # ── PHP Location ────────────────────────────────────────────────────────

    location ~ \.php$ {
        # Security: don't process PHP in uploads directory
        # (adjust path for your app)
        if ($request_uri ~* "^/wp-content/uploads") {
            return 403;
        }

        fastcgi_pass   unix:/run/php/php8.3-fpm.sock;
        fastcgi_index  index.php;
        fastcgi_param  SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include        fastcgi_params;

        # FastCGI buffering (prevents slow client from holding up PHP worker)
        fastcgi_buffering        on;
        fastcgi_buffer_size      64k;
        fastcgi_buffers          16 64k;
        fastcgi_busy_buffers_size 128k;
        fastcgi_max_temp_file_size 0;  # Don't write to disk, keep in buffer

        # Timeouts
        fastcgi_connect_timeout  10s;
        fastcgi_send_timeout     120s;
        fastcgi_read_timeout     120s;

        # FastCGI Cache
        fastcgi_cache          php_cache;
        fastcgi_cache_valid     200 10m;   # Cache 200 responses for 10 minutes
        fastcgi_cache_valid     301 302 1m; # Cache redirects for 1 minute
        fastcgi_cache_valid     404 1m;    # Cache 404 for 1 minute
        fastcgi_cache_use_stale error timeout invalid_header updating;
        fastcgi_cache_lock     on;          # Prevent cache stampede
        fastcgi_cache_background_update on; # Serve stale while refreshing

        fastcgi_cache_bypass $skip_cache;
        fastcgi_no_cache     $skip_cache;

        add_header X-Cache-Status $upstream_cache_status;
    }

    # ── WordPress-Specific ──────────────────────────────────────────────────

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

    location /wp-json/ {
        # REST API — don't cache (dynamic data)
        set $skip_cache 1;
        try_files $uri $uri/ /index.php?$args;
    }
}

 

Part 6 — Caching Architecture: Page Cache, Object Cache, and Query Cache

The original article lumps all caching into “Step 3” with three bullets. Here’s the complete, layered caching architecture:

CACHING LAYERS (outermost to innermost):
─────────────────────────────────────────────────────────────────────────
Layer 1: CDN/Edge Cache         (Cloudflare, AWS CloudFront)
  → Serves response from edge server near user
  → Best for: static files, public pages
  → Time to live: minutes to days
  → Bypass: Cache-Control: no-cache

Layer 2: Full-Page Cache        (Nginx FastCGI Cache, Varnish)
  → Returns entire HTML without touching PHP
  → Best for: public pages, anonymous users
  → TTL: 5-60 minutes
  → Bypass: logged-in users, POST requests

Layer 3: Object Cache           (Redis, Memcached)
  → Stores PHP objects, database query results
  → Best for: database results, API responses, sessions
  → TTL: 1 minute to 24 hours
  → Bypass: user-specific data (must use user-scoped keys)

Layer 4: OPcache                (PHP built-in)
  → Stores compiled PHP bytecode
  → Best for: all PHP files
  → TTL: until invalidated

Layer 5: Query Cache            (Obsolete in MySQL 8 — use application-level)
  → Replaced by Redis query result caching

Layer 6: Application Cache      (PSR-6 implementations)
  → Stores function results, computed values
  → Best for: expensive calculations, third-party API calls
─────────────────────────────────────────────────────────────────────────

Redis Setup and PHP Integration

# Install Redis:
sudo apt install redis-server php8.3-redis

# Configure Redis for performance:
sudo nano /etc/redis/redis.conf
# /etc/redis/redis.conf — performance settings

# Memory limit (set to available RAM allocated for Redis)
maxmemory 512mb

# Eviction policy — how to handle full memory
# allkeys-lru: evict least-recently-used keys (best for caching)
maxmemory-policy allkeys-lru

# Save to disk only every 15 min (comment out for pure cache performance)
save 900 1
save 300 10
save 60 10000

# TCP backlog
tcp-backlog 511

# Disable slow command logging for high-performance
slowlog-log-slower-than 10000
<?php
/**
 * Production Redis Cache Class
 * PSR-6 inspired implementation with proper error handling.
 */
class RedisCache {

    private static ?\Redis $instance = null;

    public static function connect(
        string $host     = '127.0.0.1',
        int    $port     = 6379,
        float  $timeout  = 1.0,
        string $password = '',
        int    $database = 0
    ): \Redis {

        if (self::$instance !== null) {
            return self::$instance;
        }

        $redis = new \Redis();

        try {
            // pconnect = persistent connection (reused across requests)
            $connected = $redis->pconnect($host, $port, $timeout);

            if (!$connected) {
                throw new \RuntimeException("Failed to connect to Redis at {$host}:{$port}");
            }

            if ($password !== '') {
                $redis->auth($password);
            }

            if ($database !== 0) {
                $redis->select($database);
            }

            // Set serialiser for complex PHP values
            $redis->setOption(\Redis::OPT_SERIALIZER, \Redis::SERIALIZER_IGBINARY
                ?: \Redis::SERIALIZER_PHP);

            // Set prefix to namespace keys (prevents conflicts between apps)
            $redis->setOption(\Redis::OPT_PREFIX, 'myapp:');

        } catch (\RedisException $e) {
            error_log("Redis connection failed: " . $e->getMessage());
            throw $e;
        }

        self::$instance = $redis;
        return $redis;
    }

    /**
     * Get cached value — returns null on miss.
     */
    public static function get(string $key): mixed {
        try {
            $redis = self::connect();
            $value = $redis->get($key);
            return $value === false ? null : $value;
        } catch (\Throwable $e) {
            error_log("Redis get error: " . $e->getMessage());
            return null; // Graceful degradation — don't crash if Redis is down
        }
    }

    /**
     * Set cached value with TTL in seconds.
     */
    public static function set(string $key, mixed $value, int $ttl = 3600): bool {
        try {
            $redis = self::connect();
            if ($ttl > 0) {
                return $redis->setex($key, $ttl, $value);
            }
            return $redis->set($key, $value);
        } catch (\Throwable $e) {
            error_log("Redis set error: " . $e->getMessage());
            return false;
        }
    }

    /**
     * Remember pattern — get or generate and cache.
     */
    public static function remember(string $key, int $ttl, callable $callback): mixed {
        $cached = self::get($key);
        if ($cached !== null) {
            return $cached;
        }

        $value = $callback();
        self::set($key, $value, $ttl);
        return $value;
    }

    /**
     * Invalidate keys matching a pattern.
     * Use with care — KEYS command blocks Redis on large datasets.
     * For production at scale, maintain tag-based invalidation instead.
     */
    public static function invalidatePattern(string $pattern): int {
        try {
            $redis = self::connect();
            $keys  = $redis->keys($pattern);
            return empty($keys) ? 0 : $redis->del($keys);
        } catch (\Throwable $e) {
            error_log("Redis invalidate error: " . $e->getMessage());
            return 0;
        }
    }
}

// ── Usage Examples ────────────────────────────────────────────────────────────

// Cache a database result:
$users = RedisCache::remember('users:active', 300, function() use ($pdo) {
    $stmt = $pdo->query("SELECT id, name, email FROM users WHERE active = 1");
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
});

// Cache an API response:
$weather = RedisCache::remember('weather:london', 1800, function() {
    $response = file_get_contents('https://api.weather.com/?city=london');
    return json_decode($response, true);
});

// Cache complex computed data:
$report = RedisCache::remember('report:monthly:' . date('Y-m'), 3600, function() use ($db) {
    // Expensive report generation
    return $db->generateMonthlyReport();
});

APCu (In-Process Cache — Fastest Available)

<?php
/**
 * APCu caches data in the PHP process's own memory.
 * Faster than Redis (no network round-trip) but not shared between
 * different servers — perfect for single-server setups.
 */

// Install: sudo apt install php8.3-apcu

// Cache with APCu:
function apcu_remember(string $key, int $ttl, callable $callback): mixed {
    $cached = apcu_fetch($key, $success);
    if ($success) {
        return $cached;
    }

    $value = $callback();
    apcu_store($key, $value, $ttl);
    return $value;
}

// Example:
$config = apcu_remember('app_config', 86400, function() {
    // Expensive database config loading
    return loadConfigFromDatabase();
});

 

Part 7 — Database Query Optimisation

Every millisecond saved in PHP is lost if your queries take 200ms. Database performance is usually the single largest component of PHP response time.

Identify Slow Queries

-- Enable MySQL slow query log:
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.1;  -- Log queries taking > 100ms
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
SET GLOBAL log_queries_not_using_indexes = 'ON';

-- Check current slow query log settings:
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';
# Analyse the slow query log:
mysqldumpslow -s t -t 20 /var/log/mysql/slow.log
# Shows top 20 slowest query types

# More detailed analysis:
pt-query-digest /var/log/mysql/slow.log | head -200
# Requires: sudo apt install percona-toolkit

Index Optimisation

-- Find queries not using indexes (run EXPLAIN on slow queries):
EXPLAIN SELECT * FROM orders WHERE user_id = 123 AND status = 'pending';
-- Look for type: 'ALL' (full table scan) — this needs an index

-- Add composite index for the query above:
ALTER TABLE orders ADD INDEX idx_user_status (user_id, status);

-- Verify the index is used:
EXPLAIN SELECT * FROM orders WHERE user_id = 123 AND status = 'pending';
-- Now type should be: 'ref' or 'index_merge'

-- Find duplicate indexes (waste of disk + slow writes):
SELECT t.table_name, GROUP_CONCAT(s.index_name) AS duplicate_indexes
FROM information_schema.statistics s
JOIN information_schema.tables t ON s.table_schema = t.table_schema
    AND s.table_name = t.table_name
WHERE s.table_schema = DATABASE()
GROUP BY t.table_name, s.column_name
HAVING COUNT(*) > 1;

-- Find unused indexes (MySQL 8.0 only):
SELECT object_schema, object_name, index_name
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE index_name IS NOT NULL
AND count_star = 0
AND object_schema = DATABASE()
ORDER BY object_schema, object_name;

N+1 Query Detection and Fix

The N+1 problem is the most common database performance killer in PHP apps:

<?php
// ❌ N+1 PROBLEM — 1 query to get posts + N queries to get each author
$posts = $pdo->query("SELECT id, title, user_id FROM posts LIMIT 20")->fetchAll();

foreach ($posts as &$post) {
    // This runs a NEW query for EVERY post (20 posts = 21 total queries!)
    $stmt = $pdo->prepare("SELECT name FROM users WHERE id = ?");
    $stmt->execute([$post['user_id']]);
    $post['author'] = $stmt->fetchColumn();
}

// ✅ SOLUTION 1: JOIN (single query)
$posts = $pdo->query(
    "SELECT p.id, p.title, u.name AS author
     FROM posts p
     JOIN users u ON u.id = p.user_id
     LIMIT 20"
)->fetchAll();

// ✅ SOLUTION 2: Eager loading (two queries, then merge in PHP)
$posts    = $pdo->query("SELECT id, title, user_id FROM posts LIMIT 20")->fetchAll();
$userIds  = array_unique(array_column($posts, 'user_id'));
$placeholders = implode(',', array_fill(0, count($userIds), '?'));

$usersStmt = $pdo->prepare("SELECT id, name FROM users WHERE id IN ({$placeholders})");
$usersStmt->execute($userIds);
$usersById = [];
while ($user = $usersStmt->fetch()) {
    $usersById[$user['id']] = $user['name'];
}

foreach ($posts as &$post) {
    $post['author'] = $usersById[$post['user_id']] ?? 'Unknown';
}

// Result: 2 queries instead of N+1

Connection Pooling with PDO

<?php
/**
 * Persistent PDO connections — reuse connections across requests
 * instead of creating a new connection on every PHP request.
 *
 * Note: Only use with PHP-FPM (not with Apache mod_php where
 * persistent connections can cause issues).
 */
class Database {

    private static ?PDO $instance = null;

    public static function connection(): PDO {
        if (self::$instance !== null) {
            return self::$instance;
        }

        self::$instance = new PDO(
            'mysql:host=127.0.0.1;port=3306;dbname=myapp;charset=utf8mb4',
            'dbuser',
            'dbpassword',
            [
                PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_EMULATE_PREPARES   => false,  // Use native prepared statements
                PDO::ATTR_PERSISTENT         => true,   // Persistent connection
                PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci",
                PDO::MYSQL_ATTR_FOUND_ROWS   => true,
                // Connection pool size hint (used by some MySQL proxy layers):
                // PDO::MYSQL_ATTR_COMPRESS  => true,  // For slow network connections
            ]
        );

        return self::$instance;
    }
}

// For high-concurrency, consider ProxySQL or PgBouncer for true connection pooling:
// ProxySQL sits between PHP and MySQL, maintaining a pool of MySQL connections
// and multiplexing PHP connections through them.

MySQL Configuration for PHP Workloads

# /etc/mysql/mysql.conf.d/mysqld.cnf
# Tuned for typical PHP web app workload (InnoDB, OLTP)

[mysqld]
# ── InnoDB Buffer Pool (MOST IMPORTANT MYSQL SETTING) ──────────────────────
# 70-80% of available RAM for dedicated DB server
# 25-30% if sharing server with PHP
innodb_buffer_pool_size = 1G

# Use multiple buffer pool instances for reduced contention
# (One per GB of buffer pool size, max 64)
innodb_buffer_pool_instances = 4

# ── InnoDB Log Settings ─────────────────────────────────────────────────────
# Larger log = faster writes, but longer crash recovery
innodb_log_file_size = 512M
innodb_log_buffer_size = 64M

# Flush method (O_DIRECT avoids double buffering on Linux)
innodb_flush_method = O_DIRECT

# Flush behaviour (2 = fastest writes, 1 = safest for crash recovery)
innodb_flush_log_at_trx_commit = 1  # Production: keep at 1 for ACID
# innodb_flush_log_at_trx_commit = 2  # Slight risk: 1 second data loss on crash

# ── Connection Settings ─────────────────────────────────────────────────────
max_connections = 200     # Reduce if getting "too many connections"
thread_cache_size = 50    # Cache threads for reuse
connect_timeout = 10

# ── Query Cache (DISABLED in MySQL 8+, avoid in 5.7+) ─────────────────────
# query_cache_type = 0     # Already off in MySQL 8

# ── Temp Table Settings ─────────────────────────────────────────────────────
tmp_table_size = 64M      # Temp tables in memory before writing to disk
max_heap_table_size = 64M # Max size of MEMORY tables

# ── Sort/Join Buffers (per connection — keep these smaller) ────────────────
sort_buffer_size = 2M     # Per-connection sort buffer
join_buffer_size = 2M     # Per-connection join buffer
read_buffer_size = 1M     # Sequential read buffer
read_rnd_buffer_size = 4M # Random read buffer (for ORDER BY)

# ── Slow Query Log ──────────────────────────────────────────────────────────
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 0.1    # Log queries > 100ms

# ── Binary Logging (for replication) ───────────────────────────────────────
# log_bin = /var/log/mysql/mysql-bin.log
# expire_logs_days = 7

# ── Character Set ───────────────────────────────────────────────────────────
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci

 

Part 8 — PHP Code Patterns That Kill Performance

Beyond configuration, the code itself determines performance. Here are the most impactful patterns:

Realpath Cache and File System Operations

<?php
// ── php.ini settings for filesystem performance ───────────────────────────────
// realpath_cache_size = 4096K  ← increase from default 4M
// realpath_cache_ttl  = 600    ← cache for 10 minutes

// ❌ SLOW: file_exists() on every request stat()s the filesystem
function loadTemplate(string $name): string {
    $path = "templates/{$name}.php";
    if (file_exists($path)) {  // This calls stat() every single time
        return file_get_contents($path);
    }
    return '';
}

// ✅ FAST: Cache the file existence check in APCu
function loadTemplateFast(string $name): string {
    $path = "templates/{$name}.php";

    $cacheKey = "file_exists:{$path}";
    $exists = apcu_fetch($cacheKey, $success);

    if (!$success) {
        $exists = file_exists($path);
        apcu_store($cacheKey, $exists, 300); // Cache for 5 minutes
    }

    return $exists ? file_get_contents($path) : '';
}

String Operations

<?php
// ❌ SLOW: Repeated string concatenation in a loop
$html = '';
foreach ($items as $item) {
    $html .= "<li>{$item['name']}</li>";  // Creates N new strings
}

// ✅ FAST: Output buffering or array join
$parts = [];
foreach ($items as $item) {
    $parts[] = "<li>{$item['name']}</li>";
}
$html = implode('', $parts);

// ✅ FASTEST: Direct output (no string building)
ob_start();
foreach ($items as $item) {
    echo "<li>{$item['name']}</li>";
}
$html = ob_get_clean();

Object Creation and Reuse

<?php
// ❌ SLOW: Creating new PDO, DateTime, or heavy objects repeatedly
function getUser(int $id): array {
    $pdo = new PDO(...);  // New connection on EVERY function call
    $stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
    $stmt->execute([$id]);
    return $stmt->fetch();
}

// ✅ FAST: Singleton / dependency injection
function getUser(int $id): array {
    $pdo = Database::connection();  // Reuse existing connection
    $stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
    $stmt->execute([$id]);
    return $stmt->fetch();
}

// ❌ SLOW: Creating DateTime objects in loops
$dates = [];
foreach ($timestamps as $ts) {
    $dates[] = new DateTime("@{$ts}");  // New object per iteration
}

// ✅ FAST: Create once, modify
$dt = new DateTime();
foreach ($timestamps as $ts) {
    $dt->setTimestamp($ts);
    $dates[] = $dt->format('Y-m-d');
}

JSON Performance

<?php
// ❌ SLOW: json_decode on the same data multiple times
$data = json_decode($jsonString, true);
doSomething($data);
doSomethingElse(json_decode($jsonString, true)); // Re-decodes!

// ✅ FAST: Decode once, pass around
$data = json_decode($jsonString, true);
doSomething($data);
doSomethingElse($data);

// ── json_encode flags for performance ─────────────────────────────────────
// JSON_UNESCAPED_SLASHES  — prevents unnecessary / escaping (smaller output)
// JSON_UNESCAPED_UNICODE  — prevents \uXXXX encoding (smaller output)
// JSON_THROW_ON_ERROR     — throw exception instead of returning false (PHP 7.3+)

$json = json_encode($data,
    JSON_UNESCAPED_SLASHES |
    JSON_UNESCAPED_UNICODE |
    JSON_THROW_ON_ERROR
);

// For API responses — also add Content-Encoding:
header('Content-Type: application/json; charset=utf-8');
echo $json;

Lazy Loading and Deferred Computation

<?php
// ❌ SLOW: Computing everything upfront, even if only some data is needed
function renderDashboard(int $userId): string {
    $profile      = fetchUserProfile($userId);   // Always runs
    $orders       = fetchRecentOrders($userId);  // Always runs (expensive)
    $analytics    = computeAnalytics($userId);   // Always runs (very expensive)
    $notifications = fetchNotifications($userId); // Always runs

    return render('dashboard', compact('profile', 'orders', 'analytics', 'notifications'));
}

// ✅ FAST: Lazy evaluation — only compute what the view actually needs
class LazyDashboard {
    private int   $userId;
    private array $cache = [];

    public function __construct(int $userId) {
        $this->userId = $userId;
    }

    public function __get(string $property): mixed {
        if (!array_key_exists($property, $this->cache)) {
            $this->cache[$property] = match ($property) {
                'profile'      => fetchUserProfile($this->userId),
                'orders'       => fetchRecentOrders($this->userId),
                'analytics'    => computeAnalytics($this->userId),
                'notifications'=> fetchNotifications($this->userId),
                default        => null,
            };
        }
        return $this->cache[$property];
    }
}

// Only loads what the template actually accesses:
$dashboard = new LazyDashboard($userId);
echo render('dashboard', ['data' => $dashboard]);

 

Part 9 — HTTP/2, HTTP/3, and Response Compression

Enable HTTP/2 in Nginx

# HTTP/2 is already configured in the Nginx server block above (listen 443 ssl http2;)
# Verify it's active:
curl -I --http2 https://yoursite.com/ 2>&1 | grep -i "HTTP/"
# Should show: HTTP/2 200

# Or use Chrome DevTools: Network tab → Protocol column should show "h2"

Brotli Compression (Better Than Gzip)

# Install Nginx with Brotli module:
sudo add-apt-repository ppa:ondrej/nginx
sudo apt update && sudo apt install libnginx-mod-brotli

# Add to nginx.conf http block:
# brotli on;
# brotli_comp_level 4;      # Level 4 is best balance of compression/CPU
# brotli_static on;         # Serve pre-compressed .br files if they exist
# brotli_types text/plain text/css text/javascript application/javascript
#              application/json application/xml image/svg+xml;

Response Size Optimisation in PHP

<?php
/**
 * Send compressed output with proper caching headers.
 * Works without Nginx-level gzip when needed.
 */
function sendOptimisedResponse(string $content, string $contentType = 'text/html'): void {

    // Minify HTML output
    if ($contentType === 'text/html') {
        $content = preg_replace([
            '/<!--(?!\[if).*?-->/s',     // Remove HTML comments
            '/\s{2,}/',                   // Collapse multiple spaces
            '/>\s+</',                    // Remove spaces between tags
        ], ['', ' ', '><'], $content);
    }

    // Check if client supports gzip/brotli
    $acceptEncoding = $_SERVER['HTTP_ACCEPT_ENCODING'] ?? '';
    $encoded = false;

    if (str_contains($acceptEncoding, 'br') && function_exists('brotli_compress')) {
        $content = brotli_compress($content, 4);
        header('Content-Encoding: br');
        $encoded = true;
    } elseif (str_contains($acceptEncoding, 'gzip')) {
        $content = gzencode($content, 6);
        header('Content-Encoding: gzip');
        $encoded = true;
    }

    // Caching headers
    $etag    = '"' . md5($content) . '"';
    $maxAge  = 3600; // 1 hour for cacheable content

    header("Content-Type: {$contentType}; charset=utf-8");
    header("Content-Length: " . strlen($content));
    header("ETag: {$etag}");
    header("Cache-Control: public, max-age={$maxAge}");
    header("Vary: Accept-Encoding");

    // 304 Not Modified check
    if (isset($_SERVER['HTTP_IF_NONE_MATCH']) && $_SERVER['HTTP_IF_NONE_MATCH'] === $etag) {
        http_response_code(304);
        exit;
    }

    echo $content;
}

 

Part 10 — Environment-Specific Optimisation Guides

Shared Hosting (Limited Control)

<?php
// ── What you CAN do on shared hosting ────────────────────────────────────────

// 1. Optimise php.ini via .user.ini (cPanel, Plesk):
// Create .user.ini in your web root:
// memory_limit = 256M
// max_execution_time = 60
// realpath_cache_size = 4096K
// realpath_cache_ttl = 600

// 2. Enable APCu (if available):
// Check: echo phpinfo() and look for APCu section

// 3. Implement application-level caching to files:
class FileCache {
    private string $dir;

    public function __construct(string $dir = '/tmp/app_cache') {
        $this->dir = $dir;
        if (!is_dir($dir)) mkdir($dir, 0755, true);
    }

    public function get(string $key): mixed {
        $file = $this->dir . '/' . md5($key) . '.cache';
        if (!file_exists($file)) return null;

        $data = unserialize(file_get_contents($file));
        if ($data['expires'] < time()) {
            unlink($file);
            return null;
        }
        return $data['value'];
    }

    public function set(string $key, mixed $value, int $ttl = 3600): void {
        $file = $this->dir . '/' . md5($key) . '.cache';
        file_put_contents($file, serialize([
            'value'   => $value,
            'expires' => time() + $ttl,
        ]), LOCK_EX);
    }
}

// 4. Static HTML output caching:
$cacheFile = '/tmp/page_cache/' . md5($_SERVER['REQUEST_URI']) . '.html';
$cacheTime = 300; // 5 minutes

if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTime) {
    readfile($cacheFile);
    exit;
}

ob_start();
// ... render your page normally ...
$output = ob_get_clean();
file_put_contents($cacheFile, $output);
echo $output;

WordPress-Specific Optimisations

<?php
// ── functions.php optimisations ────────────────────────────────────────────────

// 1. Disable WordPress emoji (loads JavaScript + CSS for emojis)
remove_action('wp_head', 'print_emoji_detection_script', 7);
remove_action('wp_print_styles', 'print_emoji_styles');

// 2. Disable oEmbed (auto-embeds add HTTP requests)
remove_action('wp_head', 'wp_oembed_add_discovery_links');
remove_action('wp_head', 'wp_oembed_add_host_js');

// 3. Remove unnecessary meta tags
remove_action('wp_head', 'rsd_link');
remove_action('wp_head', 'wlwmanifest_link');
remove_action('wp_head', 'wp_generator');  // Security: hides WP version

// 4. Defer non-critical scripts
add_filter('script_loader_tag', function(string $tag, string $handle): string {
    $defer_scripts = ['your-slider-script', 'contact-form-7', 'swiper'];
    if (in_array($handle, $defer_scripts, true)) {
        return str_replace(' src=', ' defer="defer" src=', $tag);
    }
    return $tag;
}, 10, 2);

// 5. Async Google Fonts
add_filter('style_loader_tag', function(string $tag, string $handle): string {
    if (str_contains($handle, 'google-fonts')) {
        return str_replace("rel='stylesheet'",
            "rel='preload' as='style' onload=\"this.rel='stylesheet'\"",
            $tag
        );
    }
    return $tag;
}, 10, 2);

// 6. Use Redis as WordPress object cache
// wp-content/object-cache.php  ← Drop-in from redis-cache plugin
// Or in wp-config.php:
// define('WP_REDIS_HOST', '127.0.0.1');
// define('WP_REDIS_PORT', 6379);
// define('WP_REDIS_DATABASE', 0);
// define('WP_REDIS_TIMEOUT', 1);
// define('WP_REDIS_READ_TIMEOUT', 1);

// 7. Limit WordPress post revisions
add_filter('wp_revisions_to_keep', fn($num, $post) => 5, 10, 2);

Laravel Optimisations

# 1. Config and route caching (massive performance improvement):
php artisan config:cache    # Merges all config files into one
php artisan route:cache     # Compiles route definitions
php artisan event:cache     # Compiles event/listener mapping
php artisan view:cache      # Pre-compiles Blade templates

# Run all at once:
php artisan optimize

# IMPORTANT: Clear caches after deployment!
php artisan optimize:clear  # On development
php artisan optimize        # On production

# 2. Autoloader optimisation (composer):
composer install --no-dev --optimize-autoloader
# --optimize-autoloader generates a classmap instead of filesystem search

# 3. Laravel queue worker (process background jobs efficiently):
php artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
# Use Supervisor to keep it running: /etc/supervisor/conf.d/laravel-worker.conf

# 4. Horizon for Redis queue monitoring:
composer require laravel/horizon
php artisan horizon

 

Part 11 — Monitoring and Alerting Setup

You cannot improve what you don’t measure continuously.

Prometheus + PHP-FPM Metrics

# Install php-fpm-exporter for Prometheus:
wget https://github.com/hipages/php-fpm_exporter/releases/download/v2.0.4/php-fpm_exporter_2.0.4_linux_amd64.tar.gz
tar -xzf php-fpm_exporter*.tar.gz
sudo mv php-fpm_exporter /usr/local/bin/

# Run as service (/etc/systemd/system/php-fpm-exporter.service):
# [Unit]
# Description=PHP-FPM Exporter
# [Service]
# ExecStart=/usr/local/bin/php-fpm_exporter server --phpfpm.scrape-uri=tcp://127.0.0.1:9000/status
# [Install]
# WantedBy=multi-user.target

Simple PHP Performance Monitoring

<?php
/**
 * Lightweight request performance monitoring.
 * Log slow requests to identify real-world bottlenecks.
 */
class RequestMonitor {

    private float  $startTime;
    private int    $startMemory;
    private static float $threshold = 0.5; // Log requests taking > 500ms

    public function __construct() {
        $this->startTime   = hrtime(true);
        $this->startMemory = memory_get_usage(true);
    }

    public function finish(): void {
        $elapsed   = (hrtime(true) - $this->startTime) / 1e6;  // ms
        $peakMB    = round(memory_get_peak_usage(true) / 1048576, 1);
        $currentMB = round(memory_get_usage(true) / 1048576, 1);

        if ($elapsed > (self::$threshold * 1000)) {
            error_log(sprintf(
                '[SLOW REQUEST] %s %s | Time: %.2fms | Memory: %sMB peak | URI: %s',
                $_SERVER['REQUEST_METHOD'] ?? 'CLI',
                $_SERVER['SERVER_NAME'] ?? 'cli',
                $elapsed,
                $peakMB,
                $_SERVER['REQUEST_URI'] ?? 'cli'
            ));
        }

        // Optionally send to metrics endpoint:
        // stats_timing('request.duration', $elapsed);
        // stats_gauge('request.memory', $peakMB);
    }
}

// Usage: Add to your index.php / bootstrap file
$monitor = new RequestMonitor();
register_shutdown_function([$monitor, 'finish']);

 

The Compound Effect of PHP Optimisation

Here’s what a systematic optimisation effort achieves on a typical PHP/WordPress site:

BEFORE OPTIMISATION (baseline):
  Response time: 420ms average
  Throughput:    12 req/sec
  Memory/worker: 80MB
  Server load:   0.8 (1 CPU)

AFTER STAGE 1: PHP 8.3 + OPcache properly configured
  Response time: 210ms  (-50%)
  Throughput:    28 req/sec (+133%)

AFTER STAGE 2: PHP-FPM tuned + Nginx FastCGI cache
  Response time: 95ms   (-77%)
  Throughput:    68 req/sec (+467%)

AFTER STAGE 3: Redis object cache + query optimisation
  Response time: 42ms   (-90%)
  Throughput:    140 req/sec (+1067%)

AFTER STAGE 4: Full-page cache for anonymous users
  Response time: 8ms    (-98%)
  Throughput:    500+ req/sec

── All changes, same hardware. ──

The original article gives you a checklist. This guide gives you the actual values, the reasoning behind each decision, and the workflow to measure whether your changes are working.

The optimisation order matters: infrastructure first (PHP version, OPcache, FPM), then caching layers (full-page → object → query), then code quality (N+1 fixes, connection pooling, lazy loading). Reversing this order wastes time — there’s no point optimising a loop in application code when the same request is making 50 database queries.

Start with the diagnostic script from Part 0. Let the measurements tell you where the bottleneck actually is. Then apply the relevant section from this guide.

 

Quick Reference: Complete php.ini Performance Settings

; /etc/php/8.3/fpm/php.ini — Production performance settings

[PHP]
; Core limits
memory_limit = 256M
max_execution_time = 60
max_input_time = 30

; Upload
upload_max_filesize = 32M
post_max_size = 33M

; Error handling (production: log, don't display)
display_errors = Off
log_errors = On
error_log = /var/log/php/php-error.log
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT

; Session performance (use Redis in production)
session.save_handler = redis
session.save_path = "tcp://127.0.0.1:6379"
session.gc_probability = 0    ; Redis handles expiry

; Filesystem performance
realpath_cache_size = 4096K
realpath_cache_ttl = 600

; OPcache (see Part 1 for full configuration)
[opcache]
opcache.enable = 1
opcache.enable_cli = 1
opcache.memory_consumption = 256
opcache.interned_strings_buffer = 64
opcache.max_accelerated_files = 32521
opcache.revalidate_freq = 0
opcache.validate_timestamps = 0
opcache.jit = tracing
opcache.jit_buffer_size = 256M
; opcache.preload = /var/www/html/preload.php
; opcache.preload_user = www-data
opcache.enable_statistics = 1

 

Frequently Asked Questions

+

Why is my PHP website running slowly?

A slow PHP website can result from outdated PHP versions, inefficient database queries, missing indexes, excessive HTTP requests, lack of caching, large images, or limited server resources.
+

How can I improve PHP website performance?

You can improve PHP performance by: Upgrading to the latest PHP version Enabling OPcache Optimizing MySQL queries Using Redis or Memcached for caching Compressing assets with GZIP or Brotli Using a CDN Optimizing images and enabling lazy loading
+

What is OPcache, and how does it improve performance?

OPcache is a PHP extension that stores compiled PHP bytecode in memory. Instead of compiling PHP scripts on every request, OPcache serves the cached version, reducing CPU usage and significantly improving response time.
+

Does upgrading to PHP 8.3 improve website speed?

Yes. PHP 8.3 includes performance improvements, optimized memory usage, and faster execution compared to older PHP versions, making it one of the easiest ways to boost website speed.
+

How can database optimization improve PHP performance?

Optimizing your database by adding indexes, reducing unnecessary queries, avoiding SELECT *, and using efficient JOIN operations can greatly reduce query execution time and improve overall application performance.
+

Should I use Redis or Memcached for PHP caching?

Both are excellent caching solutions. Redis is ideal for object caching, session storage, and complex data structures, while Memcached is lightweight and suitable for simple in-memory caching. Redis is generally preferred for modern PHP applications.
+

What role does GZIP or Brotli compression play?

Compression reduces the size of HTML, CSS, JavaScript, and other assets before they are sent to the browser. This decreases bandwidth usage and speeds up page loading, especially on slower internet connections.
+

How does a CDN improve website speed?

A Content Delivery Network (CDN) stores copies of your website's static assets on servers around the world. Visitors receive content from the nearest server, reducing latency and improving page load times.
+

Why should I optimize images on my PHP website?

Large, unoptimized images increase page load times. Using modern formats like WebP, compressing images, and implementing lazy loading can significantly improve website performance.
+

How can I identify performance bottlenecks in PHP?

Use tools such as: Xdebug Blackfire New Relic Query Monitor (for WordPress) MySQL EXPLAIN Browser Developer Tools Google PageSpeed Insights These tools help identify slow code, database queries, and resource-heavy operations.
+

Does HTTP/2 or HTTP/3 improve PHP website performance?

Yes. HTTP/2 and HTTP/3 reduce latency by allowing multiple requests over a single connection, improving page load speed and overall browsing experience.
+

What are the best practices for optimizing a PHP web server?

Some recommended practices include: Enable OPcache Use PHP-FPM Upgrade to the latest PHP version Optimize MySQL queries Enable GZIP or Brotli compression Use Redis caching Serve static assets through a CDN Minify CSS and JavaScript Optimize images Monitor server performance regularly
+

Does SSD hosting improve PHP website speed?

Yes. SSD and NVMe storage provide much faster read and write speeds than traditional hard drives, improving file access, database performance, and overall server responsiveness.
+

How can I reduce PHP execution time?

Reduce execution time by optimizing loops, minimizing database queries, caching frequently accessed data, avoiding unnecessary file operations, and using efficient algorithms and built-in PHP functions.
+

Which web server is better for PHP: Apache or Nginx?

Both are excellent choices. Apache is widely used and easy to configure, while Nginx generally offers better performance for high-traffic websites due to its event-driven architecture. Many production environments use Nginx with PHP-FPM for optimal performance.
+

Can caching improve dynamic PHP applications?

Yes. Caching frequently requested data, database query results, sessions, and compiled PHP scripts can dramatically reduce server load and improve response times for dynamic applications.
Previous Article

PHP file_get_contents() Not Working While cURL Works — The Complete Fix Guide

Next Article

Method Chaining in PHP — The Complete Developer's Guide

Write a Comment

Leave a Comment

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

Subscribe to our Newsletter

Subscribe to our email newsletter to get the latest posts delivered right to your email.
Pure inspiration, zero spam ✨