name: scalability-knowledge
description: Scalability knowledge base. Provides vertical vs horizontal scaling, stateless design, session management, connection pooling, capacity planning, and PHP-FPM tuning for scalability audits.
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