Module 3

PHP Performance

OPcache, Redis caching patterns, memory management, profiling, async queues, and HTTP caching — everything you need to ship fast PHP apps.

OPcache JIT Redis Cache Patterns Memory Profiling Queues HTTP Cache php.ini

OPcache

01

OPcache stores compiled PHP bytecode in shared memory. Subsequent requests skip tokenizing, parsing, and compiling entirely — the single highest-impact optimization for any PHP app.

📄
PHP File
source
🔍
Lexer
tokenize
🌳
Parser
AST
⚙️
Compiler
bytecode
💾
OPcache
cached here
🚀
Execute
Zend VM
Impact: With OPcache enabled, subsequent requests skip lexer → parser → compiler entirely. On a typical Laravel app this saves 50–80% of CPU time, cutting response time from ~100ms to ~20ms.

Optimal php.ini Configuration

; OPcache — production settings
opcache.enable                 = 1
opcache.enable_cli             = 1         ; also cache CLI scripts
opcache.memory_consumption     = 256       ; MB — tune to app size
opcache.interned_strings_buffer= 16        ; MB for interned strings
opcache.max_accelerated_files  = 20000     ; max cached scripts
opcache.revalidate_freq        = 0         ; NEVER check disk in prod
opcache.validate_timestamps    = 0         ; trust cache (restart to clear)
opcache.save_comments          = 1         ; required for annotations/PHPDoc
opcache.jit_buffer_size        = 100M      ; JIT buffer (PHP 8+)
opcache.jit                    = tracing   ; best JIT mode for web apps
In development: set validate_timestamps=1 so file changes are picked up immediately. In production: set validate_timestamps=0 and clear via opcache_reset() or a server restart on deploy.

Preloading 7.4+

// In php.ini:
// opcache.preload      = /var/www/preload.php
// opcache.preload_user = www-data

// preload.php — runs once at server startup
foreach (glob('/var/www/vendor/symfony/**/*.php') as $file) {
    opcache_compile_file($file);
}

// Monitor OPcache health at runtime
$status = opcache_get_status();
printf(
    "Hit rate: %.1f%% | Used: %dMB / %dMB | Scripts: %d\n",
    $status['opcache_statistics']['opcache_hit_rate'],
    $status['memory_usage']['used_memory'] / 1024 / 1024,
    $status['memory_usage']['total_memory'] / 1024 / 1024,
    $status['opcache_statistics']['num_cached_scripts'],
);

Caching Layers

02
L1 — In-process (PHP array)
< 1μs latency request
L2 — APCu (shared memory)
~ 1μs latency worker
L3 — Redis / Memcached (network)
~ 0.5ms latency all servers
L4 — Database / Filesystem
5–50ms latency persistent

Redis Cache-Aside Pattern

$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

// Cache-Aside — the most common pattern
function getUser(int $id, Redis $redis, PDO $db): array
{
    $key = "user:$id";

    // 1. Try cache first (hot path — no DB hit)
    if ($cached = $redis->get($key)) {
        return json_decode($cached, true);
    }

    // 2. Cache miss — fetch from DB
    $stmt = $db->prepare('SELECT * FROM users WHERE id = ?');
    $stmt->execute([$id]);
    $data = $stmt->fetch();

    // 3. Populate cache with a 5-minute TTL
    $redis->setex($key, 300, json_encode($data));

    return $data;
}

Cache Stampede Prevention

Cache stampede = many requests hit the database simultaneously when a popular cache key expires. Fix it with a mutex lock so only one process recomputes the value.
// Locking pattern — only one worker recomputes
function getWithLock(string $key, callable $compute, Redis $r): mixed
{
    if ($v = $r->get($key)) return unserialize($v);

    $lock = "lock:$key";
    if ($r->set($lock, 1, ['NX', 'EX' => 10])) {
        // Acquired the lock — this process computes
        try {
            $result = ($compute)();
            $r->setex($key, 300, serialize($result));
            return $result;
        } finally {
            $r->del($lock);
        }
    }

    // Others wait briefly and retry (lock held by another worker)
    usleep(50_000);
    return getWithLock($key, $compute, $r);
}

// Tag-based invalidation — bust related keys together
$r->sadd("tags:user:$userId", "user:$userId", "user:$userId:posts");

function invalidateTag(string $tag, Redis $r): void
{
    $keys = $r->smembers("tags:$tag");
    if ($keys) $r->del(...$keys);
    $r->del("tags:$tag");
}
PatternWhen to useConsistency
Cache-AsideRead-heavy, occasional writesEventual
Write-ThroughMust always have fresh dataStrong
Write-BehindHigh-write, tolerate short lagEventual
Refresh-AheadPredictable access patternsStrong

Memory Management

03
// ❌ Loads ALL rows into memory at once
$rows = $pdo->query('SELECT * FROM logs')->fetchAll(); // 1M rows → OOM

// ✅ Generator streams one row at a time — constant memory
$stmt = $pdo->query('SELECT * FROM logs');
while ($row = $stmt->fetch()) {
    processRow($row);
}

// ❌ O(n²) string concatenation — reallocates on every iteration
$out = '';
foreach ($items as $item) { $out .= $item; }

// ✅ Collect then join once — O(n)
$out = implode('', $items);

// WeakReference — hold ref without preventing garbage collection
$obj  = new HeavyObject();
$weak = WeakReference::create($obj);
unset($obj);          // object CAN be GC'd now
$weak->get();         // returns null if collected

// memory_limit check in long-running scripts
function checkMemory(): void
{
    $used  = memory_get_usage(true);
    $limit = ini_get('memory_limit');
    // Convert limit to bytes and compare...
}

Profiling

04
; php.ini — Xdebug 3 profiling
xdebug.mode                    = profile
xdebug.output_dir              = /tmp/xdebug
xdebug.profiler_output_name    = cachegrind.out.%p.%r
xdebug.start_with_request      = trigger  ; trigger via ?XDEBUG_PROFILE=1
// Simple manual profiler using hrtime() — nanosecond precision
class Profiler
{
    private static array $marks = [];

    public static function start(string $label): void
    {
        self::$marks[$label] = hrtime(true);
    }

    public static function stop(string $label): float
    {
        $ms = (hrtime(true) - self::$marks[$label]) / 1_000_000;
        error_log("[PROFILE] $label: {$ms}ms");
        return $ms;
    }
}

Profiler::start('db-query');
$users = $repo->findAll();
Profiler::stop('db-query'); // logs "db-query: 4.2ms"
ToolTypeBest for
XdebugLocal profilerCallgrind, step debugger
Blackfire.ioCloud profilerProduction profiling, CI assertions
TidewaysAPMProduction monitoring, traces
SPXLocal profilerLightweight, web UI, no overhead

Queues & Async Work

05

Move slow work out of the HTTP request. The user gets an instant response; a background worker handles the heavy lifting asynchronously.

SYNC Send welcome email during registration request +800ms
SYNC Resize uploaded image before responding +2000ms
ASYNC Dispatch SendWelcomeEmail job → respond immediately +2ms
ASYNC Dispatch ResizeImage job → respond with placeholder +2ms
// Simple Redis-backed queue
class Queue
{
    public function __construct(private readonly Redis $redis) {}

    public function push(string $queue, array $payload): void
    {
        $this->redis->rpush("queue:$queue", json_encode($payload));
    }

    public function pop(string $queue, int $timeout = 5): ?array
    {
        // BLPOP blocks until an item arrives — no polling loop needed
        $item = $this->redis->blpop("queue:$queue", $timeout);
        return $item ? json_decode($item[1], true) : null;
    }
}

// Dispatch (in HTTP handler — instant response)
$queue->push('emails', [
    'job'  => 'SendWelcomeEmail',
    'to'   => $user->email,
    'name' => $user->name,
]);

// Worker (separate process / systemd service)
while (true) {
    $job = $queue->pop('emails');
    if ($job === null) continue;

    try {
        (new $job['job'])->handle($job);
    } catch (Throwable $e) {
        error_log("Job failed: " . $e->getMessage());
        $queue->push('failed', $job + ['error' => $e->getMessage()]);
    }
}

HTTP Caching

06
// ETags — validate freshness without re-sending the response body
$etag = md5($post->updated_at . $post->id);
header("ETag: \"$etag\"");
header('Cache-Control: public, max-age=0, must-revalidate');

if (isset($_SERVER['HTTP_IF_NONE_MATCH']) &&
    trim($_SERVER['HTTP_IF_NONE_MATCH'], '"') === $etag) {
    http_response_code(304); // Not Modified — zero bytes transferred
    exit;
}

// Cache-Control directives
header('Cache-Control: public, max-age=3600, stale-while-revalidate=60');
//   public                   = CDN may cache this response
//   max-age=3600             = fresh for 1 hour
//   stale-while-revalidate=60 = serve stale for 60s while re-fetching

// Vary — separate cache entries per request header
header('Vary: Accept-Language, Accept-Encoding');

Quick Performance Wins

07
// Use isset() over array_key_exists() on hot paths
// isset: ~0.1μs | array_key_exists: ~0.4μs
if (isset($arr['key'])) { /* ... */ }

// Avoid in_array() on large arrays — flip and use isset O(1) vs O(n)
$set = array_flip($largeArray); // build once
isset($set['value']);            // O(1) lookup

// Avoid regex when simple string functions work
str_starts_with($s, 'http'); // vs preg_match
str_contains($s, '@');        // vs preg_match

// Null coalescing assign — set only if not already set
self::$cache[$k] ??= loadConfig($k);

// Stream CSV exports without buffering all rows in memory
function csvExport(PDO $db): void
{
    header('Content-Type: text/csv');
    $out  = fopen('php://output', 'w');
    $stmt = $db->query('SELECT * FROM orders');
    while ($row = $stmt->fetch()) {
        fputcsv($out, $row);
    }
    fclose($out);
}
; php.ini — production tuning
expose_php             = Off
display_errors         = Off
log_errors             = On
memory_limit           = 128M      ; tune per app
max_execution_time     = 30
realpath_cache_size    = 4096K     ; cache filesystem path lookups
realpath_cache_ttl     = 600       ; 10 minutes

; php-fpm www.conf
pm                     = dynamic
pm.max_children        = 50
pm.start_servers       = 10
pm.min_spare_servers   = 5
pm.max_spare_servers   = 20
pm.max_requests        = 1000      ; recycle workers to prevent memory leaks
OptimizationEffortImpact
Enable OPcache with validate_timestamps=0LowVery High
Fix N+1 queriesLowVery High
Add missing database indexesLowVery High
Cache expensive queries in RedisMediumHigh
Move slow work to queuesMediumHigh
Enable JIT (compute-heavy workloads)LowMedium
HTTP Cache-Control headersLowMedium
Micro-optimize PHP codeHighLow
Articles Tags Products