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
01OPcache 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");
}
| Pattern | When to use | Consistency |
|---|---|---|
| Cache-Aside | Read-heavy, occasional writes | Eventual |
| Write-Through | Must always have fresh data | Strong |
| Write-Behind | High-write, tolerate short lag | Eventual |
| Refresh-Ahead | Predictable access patterns | Strong |
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"
| Tool | Type | Best for |
|---|---|---|
| Xdebug | Local profiler | Callgrind, step debugger |
| Blackfire.io | Cloud profiler | Production profiling, CI assertions |
| Tideways | APM | Production monitoring, traces |
| SPX | Local profiler | Lightweight, web UI, no overhead |
Queues & Async Work
05Move 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
| Optimization | Effort | Impact |
|---|---|---|
Enable OPcache with validate_timestamps=0 | Low | Very High |
| Fix N+1 queries | Low | Very High |
| Add missing database indexes | Low | Very High |
| Cache expensive queries in Redis | Medium | High |
| Move slow work to queues | Medium | High |
| Enable JIT (compute-heavy workloads) | Low | Medium |
| HTTP Cache-Control headers | Low | Medium |
| Micro-optimize PHP code | High | Low |