Horizontal Scaling: Stateless PHP and Shared Infrastructure

Understand what it means for a PHP application to be stateless, why that property is required for horizontal scaling, and how to externalize sessions, files, caches, and queues so any request can be served by any server.

Vertical scaling — buying a bigger server — eventually hits a ceiling and a single point of failure. Horizontal scaling adds more, smaller servers behind a load balancer instead, so traffic and failures are spread across many machines. PHP's request-per-process model actually makes this easier than in some other stacks, but only if you avoid a specific trap: storing any state that only one server knows about. This lesson covers what 'stateless' really means for a PHP app and the shared infrastructure (sessions, storage, caches, queues) that makes true horizontal scaling possible.

What 'stateless' means for a PHP application

A stateless application server is one where any request can be handled by any instance, because no instance holds data that only it knows about. Since PHP already tears down all in-memory state at the end of every request, PHP applications are naturally closer to stateless than long-running Node.js or Java processes — the danger is almost always in things you write to local disk or infer from the local machine.

The classic trap is PHP's default session handler, which writes session data to files in /tmp (or wherever session.save_path points) on the server that happened to handle the login request. If the next request from that same browser lands on a different server, that server has never heard of the session file and the user appears logged out.

Shared session storage

The fix is to move session storage out of the local filesystem and into a store every server can reach: Redis is the standard choice, because it's fast and has built-in TTL support that matches session expiry naturally. PHP supports this natively through session.save_handler = redis and session.save_path pointing at the Redis server, requiring no application code changes for basic use, or through a custom SessionHandlerInterface implementation for more control.

Once sessions live in Redis, it genuinely does not matter which server in the pool handles a given request — every server reads and writes the same session store, and a user can be routed to a different server on every single request without ever noticing.

Shared file storage

The same trap applies to any file the application writes: user-uploaded avatars, generated PDFs, exported CSVs. If move_uploaded_file() writes to a local uploads/ directory, that file only exists on the server that processed the upload — a request to view it that lands on a different server gets a 404.

The fix is to write uploads to shared, S3-compatible object storage (AWS S3 itself, or a self-hosted equivalent like MinIO or Cloudflare R2) rather than local disk. PHP's Flysystem library abstracts this nicely, letting the same application code target local disk in development and S3 in production by swapping the configured adapter.

Shared caches and queues

An in-process cache (a static array, or PHP's APCu, which is per-server shared memory) is fast but server-local: a cache warm on server A is cold on server B, and invalidating a cache entry on one server leaves stale copies on the others. For anything that must be consistent across the fleet — computed query results, rate limit counters, feature flags — use a shared cache like Redis or Memcached instead. APCu is still useful, but only for data that's fine being recomputed independently per server (e.g. a compiled config array).

Background work (sending emails, resizing images, generating reports) should go through a shared queue (Redis-backed via a library like Laravel's queue system, or a dedicated broker like RabbitMQ or Amazon SQS) rather than being kicked off with something like a fire-and-forget shell exec on the web server — a shared queue lets any worker process, on any machine, pick up any job, and lets you scale queue workers independently from web servers.

Load balancers: distribution and health

A load balancer sits in front of the fleet of PHP servers and decides which one handles each incoming request. Round robin (cycling through servers in order) is the simplest strategy and works well once servers are stateless, since it doesn't matter which one gets picked.

Load balancers also perform health checks — periodically hitting a lightweight endpoint like /health on each server and removing any server that fails to respond (or responds slowly/with errors) from rotation, so a crashed or overloaded instance stops receiving traffic until it recovers.

Sticky sessions (routing a given client to the same server every time, usually via a cookie) are sometimes offered as a shortcut to 'fix' session problems without externalizing session storage. This is an anti-pattern to avoid where possible: it defeats even load distribution, and if that one server goes down, every client stuck to it loses their session entirely. With sessions properly externalized to Redis, sticky sessions become unnecessary.

Configuring PHP sessions to use Redis instead of local files

This requires the php-redis extension to be installed; without it, save_handler = redis silently falls back to files. Any server in the pool can now read a session written by any other server, because the data lives in Redis, not on local disk. Sessions automatically inherit Redis's own expiry mechanics, so no separate garbage-collection cron job is needed for session cleanup.

; php.ini or a per-pool configuration
session.save_handler = redis
session.save_path = "tcp://redis-cache.internal:6379?auth=secret"

; Or set at runtime, before session_start():
ini_set('session.save_handler', 'redis');
ini_set('session.save_path', 'tcp://redis-cache.internal:6379?auth=secret');
session_start();

Code Examples

Instead of calling move_uploaded_file() to a local uploads/ directory (which would only exist on the server that handled this request), the file is streamed into shared object storage through Flysystem's S3 adapter. Any server in the pool — or a CDN in front of the bucket — can retrieve it afterward using the returned path, regardless of which server processed the original upload. Expected result: Uploading an avatar on server A and viewing the profile page on server B both work correctly, because the file lives in the shared bucket, not on either server's local disk.

$storage->writeStream($path, $stream);

Common mistake: Calling move_uploaded_file($tmp, 'uploads/avatar.jpg') writes only to that one server's local disk; a subsequent request to view the avatar that's load-balanced to a different server returns 404 because the file was never replicated.

This endpoint gives the load balancer a fast, reliable way to decide whether a given server should keep receiving traffic. It checks the dependencies that actually matter (database and cache reachability) with short timeouts, and returns 503 the moment any critical dependency is unavailable, so the load balancer can pull this instance out of rotation before real user requests start failing there. Expected result: GET /health.php -> 200 {"status":"healthy","checks":{"database":"ok","redis":"ok"}} while dependencies are up; 503 the moment the database becomes unreachable, at which point the load balancer stops routing traffic to this server. Health checks should be cheap and fast (short PDO/Redis timeouts) — a slow health check can itself make the load balancer think the server is unhealthy under load, exactly when you need it most.

http_response_code($healthy ? 200 : 503);

<?php
declare(strict_types=1);

use League\Flysystem\Filesystem;
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
use Aws\S3\S3Client;

function buildStorage(): Filesystem
{
    $client = new S3Client([
        'version'     => 'latest',
        'region'      => 'us-east-1',
        'endpoint'    => getenv('S3_ENDPOINT'), // e.g. MinIO or Cloudflare R2 endpoint
        'credentials' => [
            'key'    => getenv('S3_ACCESS_KEY'),
            'secret' => getenv('S3_SECRET_KEY'),
        ],
    ]);

    $adapter = new AwsS3V3Adapter($client, bucket: 'sampleapp-uploads');
    return new Filesystem($adapter);
}

function handleAvatarUpload(array $uploadedFile, int $userId): string
{
    $storage = buildStorage();
    $extension = pathinfo($uploadedFile['name'], PATHINFO_EXTENSION);
    $path = "avatars/{$userId}." . strtolower($extension);

    $stream = fopen($uploadedFile['tmp_name'], 'r');
    $storage->writeStream($path, $stream);
    fclose($stream);

    return $path; // stored in the database; any server can later serve or generate a URL for it
}
<?php
declare(strict_types=1);

// health.php — hit by the load balancer every few seconds
header('Content-Type: application/json');

$checks = [];
$healthy = true;

try {
    $pdo = new PDO(getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASS'), [
        PDO::ATTR_TIMEOUT => 1,
    ]);
    $pdo->query('SELECT 1');
    $checks['database'] = 'ok';
} catch (PDOException $e) {
    $checks['database'] = 'failed';
    $healthy = false;
}

try {
    $redis = new Redis();
    $redis->connect('redis-cache.internal', 6379, 1.0);
    $redis->ping();
    $checks['redis'] = 'ok';
} catch (Throwable $e) {
    $checks['redis'] = 'failed';
    $healthy = false;
}

http_response_code($healthy ? 200 : 503);
echo json_encode(['status' => $healthy ? 'healthy' : 'unhealthy', 'checks' => $checks]);

Stateless PHP fleet behind a load balancer

Every PHP server is interchangeable; all state that must persist across requests lives in shared infrastructure below the fleet, not on any individual server.

Client
                     |
             Load Balancer (round robin, health checks)
            /        |         \
     PHP Server A  PHP Server B  PHP Server C   <- stateless, any can handle any request
            \        |         /
              Shared Redis (sessions, cache, rate limits)
              Shared S3 (uploaded files)
              Shared Queue (background jobs)
              Shared Database (primary + replicas)

Important Rules

A PHP server must not be the sole holder of any data needed by a future request — sessions, uploaded files, caches, and queued jobs must all live in shared, external stores.

Move session storage from local files to Redis (or another shared store) before running more than one application server.

Write user uploads and generated files to S3-compatible object storage, not local disk, so any server (or a CDN) can serve them later.

Prefer a shared cache (Redis/Memcached) for anything that must be consistent fleet-wide; reserve per-server caches like APCu for data that's fine being recomputed independently on each machine.

Route background work through a shared queue so any worker process on any machine can pick up any job.

Avoid sticky sessions where possible — they undermine load distribution and turn a single server's failure into a session loss for every client stuck to it; properly shared session storage removes the need for them.

Where this shows up

Nearly every production web application above a certain traffic level runs this way: multiple stateless app servers behind a load balancer (often an AWS Application Load Balancer, Nginx, or HAProxy), Redis for sessions and caching, S3 for uploads, and a queue (SQS, RabbitMQ, or a Redis-backed queue) for background work. This is also exactly what lets platforms auto-scale — spinning up new PHP servers during a traffic spike and terminating them afterward — because a brand-new server can serve real traffic correctly the instant it boots, with zero warm-up state to lose.

// Default session config: writes to local /tmp on whichever server handles the login session_start();

ini_set('session.save_handler', 'redis'); ini_set('session.save_path', 'tcp://redis-cache.internal:6379'); session_start();

With the default file-based handler, a session created on server A is invisible to server B; a user load-balanced to a different server on their next request appears logged out. Redis-backed sessions are visible to every server.

// Configuring sticky sessions on the load balancer to 'fix' the above // (every client from a given cookie always routed to the same server)

// Externalize session storage to Redis, then let the load balancer distribute freely (round robin)

Sticky sessions paper over the real problem instead of fixing it: they create uneven load distribution and mean that if the one server a client is stuck to goes down, that client loses their session entirely — a properly shared session store avoids both issues.

[Multiple Choice] Why do PHP's default file-based sessions break in a horizontally scaled deployment? A. PHP files cannot store session data at all B. The session file is written only to the local disk of the server that handled that request, so other servers can't read it C. Sessions expire too quickly by default D. File-based sessions are slower than database sessions

B. The session file is written only to the local disk of the server that handled that request, so other servers can't read it — The default handler writes session data to a local directory on whichever server processed the request; if a later request from the same client lands on a different server, that server has no access to the file and treats the user as unauthenticated.

[True / False] Sticky sessions are the recommended long-term solution for keeping user sessions working across multiple load-balanced servers. A. True B. False

B. False — False. Sticky sessions are an anti-pattern that undermines even load distribution and causes total session loss if the pinned server fails. Externalizing sessions to a shared store like Redis is the correct fix.

[Multiple Choice] What is the main purpose of a load balancer's health check against each server? A. To measure how fast each server's CPU is B. To automatically remove a failing or unresponsive server from rotation so it stops receiving traffic C. To restart the server automatically D. To compress HTTP responses

B. To automatically remove a failing or unresponsive server from rotation so it stops receiving traffic — A health check is a periodic probe (e.g. hitting /health) that lets the load balancer detect an unhealthy server and stop routing new requests to it until it recovers, preventing users from hitting a broken instance.

You inherit a PHP app where login sessions are stored with PHP's default file-based handler, and profile picture uploads are saved with move_uploaded_file() into a local public/uploads/ directory. The app is about to be deployed across 3 load-balanced servers. Identify both problems and describe the fix for each in one or two sentences.

Problem 1: file-based sessions only exist on the server that created them, so a user routed to a different server on their next request appears logged out. Fix: switch session.save_handler to redis pointed at a shared Redis instance. Problem 2: uploaded avatars only exist on the server that received the upload, so viewing the profile from a different server returns a broken image / 404. Fix: write uploads to shared S3-compatible object storage (e.g. via Flysystem) instead of local disk, and serve them from there (optionally through a CDN).

Think about what happens when a follow-up request from the same user lands on a different server than the one that handled their login or upload.