PHP Performance Deep Dive
Basic OPcache and query tuning get you far. Production systems at scale need deeper analysis of the runtime, process model, and data layer. This page covers advanced techniques beyond introductory optimization.
Request Lifecycle and Hot Paths
Every HTTP request in PHP-FPM follows:
Nginx → FastCGI → PHP-FPM worker → Bootstrap → Autoload → App logic → Response
The bootstrap cost (framework kernel, DI container, route compilation) dominates short requests. Measure with:
// At the very top of public/index.php
define('LARAVEL_START', microtime(true));
// Framework reports bootstrap time in debug toolbar
Tools: Blackfire, Tideways, SPX, Laravel Telescope, Symfony Web Profiler.
JIT Compiler Tuning (PHP 8+)
JIT compiles hot bytecode to machine code. Configure in php.ini:
opcache.enable=1
opcache.jit_buffer_size=128M
opcache.jit=1255
JIT mode 1255 = tracing JIT, optimize all functions. Useful for CPU-heavy loops; often marginal for typical CRUD apps dominated by I/O.
| Workload | JIT benefit |
|---|---|
| Numerical computation, parsers | High |
| Typical web CRUD + MySQL | Low to moderate |
| Serialization-heavy APIs | Moderate |
Benchmark your app — JIT increases memory usage and can hurt I/O-bound workloads.
PHP-FPM Pool Sizing
Worker exhaustion causes 502 errors under load. Tune 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 = 500
Estimate max_children:
max_children ≈ (Available RAM for PHP) / (Average request memory)
Example: 4 GB for PHP, 80 MB per request → ~50 workers. Monitor with pm.status_path and tools like php-fpm_exporter.
Slow Request Logging
request_slowlog_timeout = 2s
slowlog = /var/log/php-fpm/slow.log
Correlate slow logs with APM traces to find N+1 queries or blocking external calls.
Memory Profiling
$before = memory_get_usage(true);
$users = User::with('posts.comments')->get(); // eager load
$after = memory_get_usage(true);
error_log('Memory delta: ' . ($after - $before));
For deep analysis:
- Xdebug memory profiling (dev only — significant overhead)
- meminfo extension — heap snapshots
- PHPUnit
@group memorytests withmemory_get_peak_usage()
Generators for Large Datasets
function readLargeCsv(string $path): Generator {
$handle = fopen($path, 'r');
while (($row = fgetcsv($handle)) !== false) {
yield $row;
}
fclose($handle);
}
foreach (readLargeCsv('export.csv') as $row) {
processRow($row); // constant memory regardless of file size
}
Database Layer at Scale
Connection Pooling
PHP-FPM workers each hold DB connections. At 50 workers × 3 services = 150 connections. Use:
- ProxySQL or PgBouncer (for PostgreSQL)
- RDS Proxy on AWS
- Persistent connections cautiously (
PDO::ATTR_PERSISTENT) — can exhaust server slots
Read Replicas
// Laravel: configure read/write connections in config/database.php
'mysql' => [
'read' => ['host' => ['replica1', 'replica2']],
'write' => ['host' => ['primary']],
'sticky' => true,
],
Route read-heavy reporting queries to replicas; writes stay on primary.
Query Plan Analysis
EXPLAIN ANALYZE SELECT u.id, COUNT(o.id)
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.created_at > '2024-01-01'
GROUP BY u.id;
Watch for sequential scans, filesort, and temporary tables. Add composite indexes matching WHERE + JOIN + ORDER BY columns.
Multi-Layer Caching Architecture
Browser → CDN → Reverse proxy (Varnish) → App cache (Redis) → OPcache → Database
Cache Stampede Prevention
function getCachedUsers(Redis $redis, callable $loader): array {
$key = 'users:active';
$data = $redis->get($key);
if ($data !== false) {
return json_decode($data, true);
}
$lock = $redis->set('lock:' . $key, '1', ['NX', 'EX' => 10]);
if ($lock) {
$users = $loader();
$redis->setex($key, 300, json_encode($users));
$redis->del('lock:' . $key);
return $users;
}
usleep(100_000);
return getCachedUsers($redis, $loader);
}
Use probabilistic early expiration or Symfony’s CacheInterface stampede protection.
Async and Queues
PHP is synchronous per request — offload slow work:
// Laravel queue job
class SendWelcomeEmail implements ShouldQueue {
public function __construct(public User $user) {}
public function handle(): void {
Mail::to($this->user)->send(new WelcomeMail());
}
}
SendWelcomeEmail::dispatch($user);
Run workers with Supervisor:
[program:laravel-worker]
command=php /var/www/artisan queue:work redis --sleep=3 --tries=3
numprocs=4
autostart=true
autorestart=true
For high-throughput ingestion, consider RoadRunner or FrankenPHP with early response patterns.
Horizontal Scaling
Load Balancer
/ | \
App-1 App-2 App-3
\ | /
Redis (sessions/cache)
MySQL primary
MySQL replica(s)
Requirements for stateless PHP nodes:
- Session storage in Redis, not local files
- Shared or object storage for uploads (S3)
- Centralized logging (ELK, Loki)
- Deploy with zero-downtime (blue/green or rolling)
Observability Stack
| Signal | Tools |
|---|---|
| Metrics | Prometheus + php-fpm_exporter, Grafana |
| Traces | OpenTelemetry PHP SDK, Jaeger |
| Logs | Monolog → structured JSON → Loki/ELK |
| Errors | Sentry, Bugsnag |
// Structured logging with Monolog
$logger->info('order.created', [
'order_id' => $order->id,
'user_id' => $order->user_id,
'duration_ms' => $elapsed,
]);
Set SLIs: p95 latency, error rate, queue depth. Alert on saturation before users notice.
Production Checklist
-
composer install --no-dev --optimize-autoloader -
php artisan config:cache/route:cache(Laravel) - OPcache
validate_timestamps=0with deploy-time reset - Realpath cache tuned for many files
- Preload critical classes (
opcache.preload) - Load test with k6 or Locust before launch
- Document rollback procedure
Performance is a continuous discipline: profile, fix the largest bottleneck, measure again, and automate monitoring so regressions surface immediately.