Scalability Knowledge Base
Quick reference for scalability patterns, stateless design, PHP-FPM tuning, and capacity planning in PHP applications.
Vertical vs Horizontal Scaling
| Aspect | Vertical (Scale Up) | Horizontal (Scale Out) |
|---|
| Approach | Bigger server (CPU, RAM) | More servers |
| Cost curve | Exponential (diminishing returns) | Linear (commodity hardware) |
| Downtime | Often required for upgrade | Zero-downtime rolling deploys |
| Limit | Hardware ceiling | Theoretically unlimited |
| Complexity | Low (single server) | High (distributed system) |
| Data consistency | Simple (single node) | Requires distributed coordination |
| Failure blast radius | Entire application | Single instance |
| PHP suitability | Quick win, limited ceiling | Natural fit (shared-nothing) |
When to Use Each
| Scenario | Strategy | Why |
|---|
| Early stage, simple app | Vertical | Cheapest, simplest |
| Read-heavy workload | Horizontal + read replicas | Distribute read load |
| Write-heavy workload | Horizontal + sharding | Distribute write load |
| Unpredictable traffic | Horizontal + auto-scaling | Elastic capacity |
| Legacy monolith | Vertical first, then decompose | Buys time for refactoring |
Stateless vs Stateful: PHP Shared-Nothing Architecture
PHP is shared-nothing by design — each request starts with a clean process, no shared memory between requests. This is a natural advantage for horizontal scaling.
┌─────────────────────────────────────────────────────────────────────────┐
│ SHARED-NOTHING ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Load Balancer │
│ │ │
│ ┌───┼───────────────────┬─────────────────────┐ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ PHP-FPM │ │ PHP-FPM │ │ PHP-FPM │ │
│ │ Worker 1 │ │ Worker 2 │ │ Worker N │ │
│ │ │ │ │ │ │ │
│ │ No shared│ │ No shared│ │ No shared│ │
│ │ state │ │ state │ │ state │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ └───────────────────┼─────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ External State │ │
│ │ Redis / DB │ │
│ └─────────────────┘ │
│ │
│ Key Principle: ANY request can be served by ANY worker. │
│ State lives in external stores (Redis, DB), NOT in process memory. │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Stateless Checklist
| Requirement | Stateless | Stateful (Problem) |
|---|
| Session data | Redis / JWT | $_SESSION with file storage |
| File uploads | Object storage (S3) | Local filesystem |
| Cache | Redis / Memcached | APCu (per-process) |
| Configuration | Env vars / config service | Local config files that vary per server |
| Scheduled jobs | Centralized scheduler | Local cron per server |
| WebSocket state | Redis pub/sub | In-memory connections |
Session Management
File-Based Sessions (Problem)
Server A: session file → /tmp/sess_abc123
Server B: no session file → user logged out!
Sticky sessions (workaround) → couples user to server → defeats horizontal scaling
Redis Sessions (Solution)
<?php
declare(strict_types=1);
namespace Infrastructure\Session;
final readonly class RedisSessionConfig
{
public function __construct(
private string $redisHost,
private int $redisPort = 6379,
private string $redisPrefix = 'sess:',
private int $ttlSeconds = 1800,
) {}
public function configure(): void
{
ini_set('session.save_handler', 'redis');
ini_set('session.save_path', sprintf(
'tcp://%s:%d?prefix=%s&timeout=2',
$this->redisHost,
$this->redisPort,
$this->redisPrefix,
));
ini_set('session.gc_maxlifetime', (string) $this->ttlSeconds);
}
}
JWT Stateless Alternative
<?php
declare(strict_types=1);
namespace Infrastructure\Auth;
final readonly class JwtTokenFactory
{
public function __construct(
private string $secretKey,
private string $algorithm = 'HS256',
private int $ttlSeconds = 3600,
) {}
/**
* @param array<string, mixed> $claims
*/
public function create(string $userId, array $claims = []): string
{
$header = base64_encode(json_encode([
'alg' => $this->algorithm,
'typ' => 'JWT',
], JSON_THROW_ON_ERROR));
$payload = base64_encode(json_encode(array_merge($claims, [
'sub' => $userId,
'iat' => time(),
'exp' => time() + $this->ttlSeconds,
]), JSON_THROW_ON_ERROR));
$signature = base64_encode(hash_hmac(
'sha256',
sprintf('%s.%s', $header, $payload),
$this->secretKey,
true,
));
return sprintf('%s.%s.%s', $header, $payload, $signature);
}
}
Connection Pooling
PHP creates a new database connection per request (shared-nothing). Without pooling, high-concurrency scenarios exhaust database connections.
Why PHP Needs External Poolers
| Problem | Cause | Solution |
|---|
| Connection exhaustion | Each PHP-FPM worker opens own connection | pgbouncer / ProxySQL |
| Connection overhead | TCP handshake + auth per request | Persistent connections |
| Idle connections | Workers hold connections while waiting for I/O | External pooler reclaims idle |
| Max connections limit | PostgreSQL default 100, MySQL 151 | Pooler multiplexes |
Connection Pool Wrapper
<?php
declare(strict_types=1);
namespace Infrastructure\Database;
final readonly class ConnectionPoolConfig
{
public function __construct(
private string $host,
private int $port,
private string $database,
private string $user,
private string $password,
private bool $persistent = true,
private int $connectTimeoutSeconds = 5,
private int $statementTimeoutMs = 30000,
) {}
public function createPdo(): \PDO
{
$dsn = sprintf(
'pgsql:host=%s;port=%d;dbname=%s',
$this->host,
$this->port,
$this->database,
);
$options = [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
\PDO::ATTR_EMULATE_PREPARES => false,
\PDO::ATTR_PERSISTENT => $this->persistent,
\PDO::ATTR_TIMEOUT => $this->connectTimeoutSeconds,
];
$pdo = new \PDO($dsn, $this->user, $this->password, $options);
$pdo->exec(sprintf(
'SET statement_timeout = %d',
$this->statementTimeoutMs,
));
return $pdo;
}
}
External Pooler Architecture
┌──────────────────────────────────────────────────────────────────┐
│ CONNECTION POOLING │
├──────────────────────────────────────────────────────────────────┤
│ │
│ PHP-FPM Workers (200+) │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ W1 │ │ W2 │ │ W3 │ │ ... │ │ W200 │ │
│ └──┬───┘ └──┬───┘ └──┬───┘ └──┬───┘ └──┬───┘ │
│ │ │ │ │ │ │
│ └────────┴────────┴────┬───┴────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ pgbouncer / │ Pool: 20-50 connections │
│ │ ProxySQL │ Mode: transaction │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ PostgreSQL / │ max_connections: 100 │
│ │ MySQL │ │
│ └─────────────────┘ │
│ │
│ 200 PHP workers share 20-50 DB connections via pooler │
│ │
└──────────────────────────────────────────────────────────────────┘
Capacity Planning
Amdahl's Law
Speedup = 1 / ((1 - P) + P/N)
P = parallelizable fraction of workload
N = number of processors/instances
Example: If 90% of work is parallelizable (P=0.9), 10 servers:
Speedup = 1 / ((1 - 0.9) + 0.9/10) = 1 / (0.1 + 0.09) = 5.26x
Lesson: Serial bottlenecks (DB writes, locks) limit scaling.
Little's Law
L = λ × W
L = average number of concurrent requests
λ = arrival rate (requests/second)
W = average response time (seconds)
Example: 500 req/s, 200ms avg response time:
L = 500 × 0.2 = 100 concurrent requests needed
Lesson: To handle 500 req/s at 200ms, you need capacity for 100 concurrent requests.
Throughput Formula
Throughput = Workers / Avg_Response_Time
Example: 50 PHP-FPM workers, 100ms avg:
Throughput = 50 / 0.1 = 500 req/s
To increase throughput:
1. Add more workers (horizontal scaling)
2. Reduce response time (optimization)
3. Both
PHP-FPM Scaling
Worker Calculation Formula
pm.max_children = Available_Memory / Avg_Worker_Memory
Example:
Server RAM: 4 GB
OS + overhead: 512 MB
Available: 3584 MB
Avg PHP worker: 40 MB
pm.max_children = 3584 / 40 = 89 workers
Process Manager Modes
| Mode | pm.max_children | Workers | Use Case |
|---|
static | Fixed pool size | Always running | Stable, predictable traffic |
dynamic | Max pool size | Scale between min/max | General purpose, variable traffic |
ondemand | Max pool size | Created per request, killed after idle | Low-traffic, memory-constrained |
Recommended Settings
| Setting | Static Mode | Dynamic Mode | Ondemand Mode |
|---|
pm | static | dynamic | ondemand |
pm.max_children | 89 | 89 | 89 |
pm.start_servers | — | 20 | — |
pm.min_spare_servers | — | 10 | — |
pm.max_spare_servers | — | 30 | — |
pm.max_requests | 500 | 500 | 500 |
pm.process_idle_timeout | — | — | 10s |
OPcache Preloading (PHP 8.4)
; php.ini — OPcache settings for production
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.preload=/app/config/preload.php
opcache.preload_user=www-data
opcache.jit=1255
opcache.jit_buffer_size=128M
<?php
declare(strict_types=1);
namespace App\Config;
// preload.php — Preload hot classes into OPcache at FPM startup
// All preloaded classes are available without autoloading overhead
$classMap = [
__DIR__ . '/../src/Domain/Entity/',
__DIR__ . '/../src/Domain/ValueObject/',
__DIR__ . '/../src/Application/UseCase/',
];
foreach ($classMap as $directory) {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($directory),
);
/** @var \SplFileInfo $file */
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
opcache_compile_file($file->getRealPath());
}
}
}
Quick Reference Tables
Scaling Decision Matrix
| Signal | Action | Implementation |
|---|
| CPU > 80% sustained | Add instances or upgrade CPU | Horizontal + auto-scaling |
| Memory > 85% | Reduce worker count or add RAM | pm.max_children tuning |
| Response time > SLA | Profile + optimize or add capacity | APM + horizontal scaling |
| Connection pool exhausted | Add pooler or increase pool | pgbouncer / ProxySQL |
| Disk I/O bottleneck | Move to SSD, offload to object storage | Infrastructure change |
| Request queue growing | Add PHP-FPM workers | pm.max_children increase |
Common Bottlenecks
| Bottleneck | Symptom | Fix |
|---|
| Database queries | Slow response, high DB CPU | Query optimization, caching, read replicas |
| Session storage | Inconsistent sessions across servers | Redis sessions |
| File uploads | Disk I/O, storage limits | Object storage (S3) |
| External API calls | Timeout, high latency | Circuit breaker, async processing |
| PHP-FPM workers | 502/504 errors, request queue | Increase pm.max_children |
| OPcache | Slow first requests after deploy | Preloading, warm-up scripts |
Detection Patterns
# PHP-FPM configuration
Grep: "pm\.max_children|pm\.start_servers|pm\.min_spare|pm\.max_spare" --glob "**/php-fpm*.conf"
Grep: "pm\.max_children|pm\.start_servers" --glob "**/www.conf"
Grep: "pm\.max_requests|pm\.process_idle_timeout" --glob "**/php-fpm*.conf"
# OPcache settings
Grep: "opcache\." --glob "**/php.ini"
Grep: "opcache_compile_file|opcache_reset" --glob "**/*.php"
Grep: "opcache\.preload" --glob "**/php.ini"
# Session configuration
Grep: "session\.save_handler|session\.save_path" --glob "**/php.ini"
Grep: "session_start|SESSION" --glob "**/*.php"
Grep: "Redis.*session|session.*redis" --glob "**/*.php"
# Connection pooling
Grep: "PDO::ATTR_PERSISTENT|ATTR_PERSISTENT" --glob "**/*.php"
Grep: "pgbouncer|proxysql" --glob "**/docker-compose*.yml"
# Stateless violations
Grep: "file_put_contents|fwrite.*tmp" --glob "**/src/**/*.php"
Grep: "\\\$_SESSION" --glob "**/src/**/*.php"
Grep: "apc_store|apcu_store" --glob "**/src/**/*.php"
# Scaling indicators
Grep: "HORIZONTAL_SCALE|AUTO_SCALE|REPLICAS" --glob "**/.env*"
Grep: "replicas:|scale:" --glob "**/docker-compose*.yml"
References
For detailed information, load these reference files:
references/scaling-patterns.md — Horizontal scaling strategies, auto-scaling triggers, read replicas, write scaling, caching as scaling tool
references/php-specifics.md — PHP-FPM tuning, OPcache settings, shared-nothing architecture, persistent connections, external poolers, real-time alternatives