Load Balancers and Building Stateless PHP Applications

Explain what a load balancer does and why it forces a PHP application to become stateless, distinguish horizontal from vertical scaling, and configure the shared infrastructure (sessions, cache, object storage) a stateless PHP app needs to run correctly behind multiple servers.

Every PHP application you've built so far in this course has almost certainly run on a single machine: one server, one PHP-FPM pool, one filesystem holding sessions and uploaded files. That model works fine until traffic outgrows what one server can handle, or until you need the app to survive a single server dying without taking the whole site down with it. The standard fix is to run multiple copies of your application behind a load balancer — but this single architectural change quietly breaks a huge number of assumptions that 'PHP on one server' code makes for granted, particularly around sessions, caching, and file uploads. This lesson covers what a load balancer actually does, why it demands a stateless application, and the shared infrastructure that makes statelessness practical rather than just theoretical.

What a Load Balancer Actually Does

A load balancer sits in front of your application servers and is the single entry point clients connect to. Instead of a browser talking directly to one PHP-FPM server, it talks to the load balancer, which then forwards (proxies) that request to one of several backend application servers running an identical copy of your codebase. The load balancer's core job is to distribute incoming requests across those backends according to some algorithm — round robin (rotate through servers in order), least connections (send the next request to whichever backend currently has the fewest open connections), or weighted variants that account for servers with different capacity.

Beyond distribution, a load balancer typically performs health checks: it periodically pings each backend (often hitting a dedicated /health endpoint that quickly verifies the app and its database connection are alive) and automatically stops routing traffic to any backend that fails to respond correctly. This is what makes a multi-server setup resilient — if one server crashes, hangs, or is taken down for maintenance, the load balancer simply routes around it, and users never notice as long as at least one healthy backend remains. Common software load balancers include Nginx (acting as a reverse proxy), HAProxy, and Traefik; cloud providers also offer managed load balancers (AWS ELB/ALB, Google Cloud Load Balancing, DigitalOcean Load Balancers) that handle the same job as a managed service.

Critically, from the load balancer's point of view every backend server is interchangeable — request 1 might land on server A, and the very next request from the same browser, even a few hundred milliseconds later, might land on server B. Nothing guarantees that a given user's requests keep hitting the same server, unless you explicitly configure 'sticky sessions' (routing based on a cookie) — and even sticky sessions are just a band-aid, not a real solution, because they reintroduce a single point of failure per user and complicate scaling down. The healthy assumption to design around is: any request can land on any server, at any time.

• Load balancer = single entry point that distributes requests across multiple identical backend app servers. • Distribution algorithms: round robin, least connections, weighted variants. • Health checks let the load balancer stop routing to a server that's down, crashed, or unhealthy. • Software options: Nginx, HAProxy, Traefik; managed options: AWS ALB/ELB, GCP Load Balancing, DigitalOcean Load Balancers. • Key assumption to design for: consecutive requests from the same user may land on different servers.

Why This Forces PHP to Be Stateless

A 'stateless' application, in this context, means no backend server holds any piece of information that a later request from the same user actually depends on being present on that specific machine. This matters because the two things a default PHP install is most tempted to store locally — sessions and uploaded files — are exactly the kind of per-user state that breaks the moment a load balancer is involved.

By default, session.save_handler in PHP is files, meaning session data is written to a directory like /var/lib/php/sessions on whichever server happened to handle the login request. If a user logs in on server A (writing their session file to server A's local disk) and their next request is routed to server B by the load balancer, server B has no idea that session exists — PHP on server B will report the user as logged out, or worse, silently issue a fresh session ID, producing intermittent, maddening 'random logouts' that are notoriously hard to diagnose because they only happen sometimes, depending on which server you happen to land on.

The exact same problem hits file uploads. If your upload handler does move_uploaded_file($tmp, '/var/www/uploads/avatar.jpg'), that file now exists only on the local disk of whichever server processed the upload. A subsequent request to view that avatar, routed to a different server, gets a 404 — the file simply isn't there. This is one of the most common 'it worked in testing but broke in production' bugs teams hit the first time they scale a PHP app past a single server, because local development and small deployments never surface it.

• Stateless = no server holds per-user data that a later request depends on finding on that same machine. • Default PHP file-based sessions live on one server's local disk — invisible to every other server. • Symptom of session state on one server: intermittent random logouts depending on which backend you hit. • Local file uploads have the identical problem: a file saved on server A is a 404 on server B.

Horizontal vs Vertical Scaling

There are two fundamentally different ways to give an application more capacity. Vertical scaling means making a single server bigger — more CPU cores, more RAM, faster disks — while keeping the count of servers at one. It's simple (no architectural changes needed) but has a hard ceiling: at some point you're paying for the largest instance a cloud provider offers and you still can't buy a bigger one, and a vertically-scaled single server is still a single point of failure — if it goes down, the entire application goes down with it.

Horizontal scaling means adding more servers running the same application, rather than making one server larger. This is where load balancers become mandatory, because something has to distribute traffic across those now-multiple servers. Horizontal scaling has effectively no ceiling (cloud providers let you spin up dozens or hundreds of identical instances) and it also buys you fault tolerance for free — if one of ten servers dies, you've lost 10% of capacity, not 100% of the application, and the load balancer's health checks route around the failure automatically.

The catch is that horizontal scaling only works cleanly for a stateless application. A stateful app (one relying on local sessions, local cache, local uploads) can still be horizontally scaled in a limited, painful way using sticky sessions, but it never gets the full benefit — you can't freely add or remove servers, auto-scale based on traffic, or lose a server without also losing whatever state was pinned to it. This is precisely why 'go stateless' is the prerequisite step before 'add a load balancer', not an afterthought: statelessness is what makes horizontal scaling actually deliver on its promise.

• Vertical scaling: bigger single server; simple but has a ceiling and remains a single point of failure. • Horizontal scaling: more identical servers behind a load balancer; effectively unlimited and fault-tolerant. • Horizontal scaling requires a load balancer to distribute traffic across the servers. • Statelessness is the prerequisite that makes horizontal scaling actually work well, not an optional nicety.

Shared Session Storage: Moving Off the Local Filesystem

The fix for the session problem is to move session storage off any individual server's local disk and into a shared store that every backend server can read and write to identically. Redis is the most common choice — it's fast (in-memory), supports key expiration natively (which maps perfectly onto session TTLs), and PHP has a built-in session.save_handler for it. A database-backed session handler (writing session rows to MySQL/PostgreSQL) is a slower but perfectly valid alternative when you don't want to run a separate Redis instance, and Memcached is another common option for the same purpose.

Once sessions live in Redis, it no longer matters which backend server handles a given request: server A writes the session to Redis on login, and when the next request is routed to server B, server B reads that exact same session data from the same Redis instance. The application code that calls $_SESSION['user_id'] doesn't change at all — only the storage backend PHP uses under the hood changes, which is one of the nicest things about PHP's session handler abstraction.

Configuring this is usually a one-line change to php.ini (or an equivalent runtime ini_set() call very early in the request, before session_start() is called) plus installing the redis PHP extension (via PECL or your package manager). No changes are needed to how your application reads or writes $_SESSION — the abstraction genuinely holds.

• session.save_handler = redis (or a custom database-backed handler) replaces the default files handler. • Redis's native key expiration lines up naturally with session TTLs. • Application code using $_SESSION is completely unchanged — only the storage backend configuration changes. • Requires the redis PHP extension installed and enabled on every application server.

Shared Cache and Shared Object Storage

The same local-vs-shared problem applies to caching. APCu is a popular PHP opcode-cache-adjacent extension for storing arbitrary values in memory, but it's strictly per-process/per-server memory — a value cached with apcu_store() on server A is invisible to server B, which means a multi-server app using APCu for things like rate-limit counters or computed results will get inconsistent behavior depending on which server answers a given request (worse, each server independently re-computes and re-caches the same expensive value, defeating much of the point of caching). The fix, again, is a shared cache: Redis or Memcached running as a separate service that every application server talks to over the network, so a value cached by any server is immediately visible to all of them.

File uploads need the same treatment via object storage — a network-accessible storage service (covered in depth in the next lesson) such as AWS S3, MinIO, or DigitalOcean Spaces, rather than a path on local disk. When an avatar or document is uploaded, the application uploads it to the object storage service and stores only a URL or object key in the database; every server, regardless of which one handled the original upload, can then serve or reference that file identically because it lives outside any individual server entirely.

Put together, the pattern for a properly stateless PHP app behind a load balancer is: sessions in Redis (or a database), cache in Redis/Memcached, uploaded/user-generated files in object storage, and the only thing that varies between application servers is which one happens to be executing a given request at a given moment — everything that request needs to know is reachable from shared, external infrastructure rather than baked into the local filesystem or process memory of any single machine.

• APCu and other local/in-process caches are invisible across servers — use Redis/Memcached instead for anything that must be consistent. • Uploaded and user-generated files belong in object storage (S3/MinIO/Spaces), not on local disk. • The database itself is already a natural shared resource every backend already talks to — the goal is to extend that same 'shared, external' principle to sessions, cache, and files. • End state: every application server is disposable and interchangeable; all real state lives in shared infrastructure.

Configuring PHP Sessions to Use Redis

session.save_path accepts multiple comma-separated Redis servers for basic failover: "tcp://redis1:6379, tcp://redis2:6379". Requires the PECL redis extension (phpredis) to be installed and enabled — check with php -m | grep redis. No application code changes are needed: $_SESSION continues to work exactly as before.

; php.ini (or a per-pool .conf override for PHP-FPM)
session.save_handler = redis
session.save_path = "tcp://redis.internal:6379?auth=your_redis_password"

; Optional: a short-lived TTL matching your desired session lifetime
session.gc_maxlifetime = 1440

Code Examples

Whether session.save_handler is 'files' on one lone server or 'redis' shared across twenty servers, this code never changes — PHP's session abstraction is precisely what lets you scale horizontally without rewriting authentication logic. Expected result: Welcome back, user #42

A real health check verifies the application can reach its critical dependencies (here, the database), not just that PHP-FPM is running — a server that's up but can't reach the database should still be pulled out of rotation by the load balancer. Keep health checks fast (a couple of seconds at most) and cheap — they run frequently, on every backend, forever.

<?php
declare(strict_types=1);

session_start(); // reads php.ini's session.save_handler automatically

if (isset($_POST['username'], $_POST['password'])) {
    // ... verify credentials against the database ...
    $_SESSION['user_id'] = 42;
    $_SESSION['logged_in_at'] = time();
}

if (isset($_SESSION['user_id'])) {
    echo "Welcome back, user #" . $_SESSION['user_id'];
} else {
    echo "Not logged in.";
}
<?php
declare(strict_types=1);

// public/health.php — the load balancer polls this on every backend.
header('Content-Type: application/json');

try {
    $pdo = new PDO('mysql:host=db.internal;dbname=app', 'app', 'secret', [
        PDO::ATTR_TIMEOUT => 2,
    ]);
    $pdo->query('SELECT 1');
    http_response_code(200);
    echo json_encode(['status' => 'ok']);
} catch (Throwable $e) {
    http_response_code(503);
    echo json_encode(['status' => 'unhealthy']);
}

Stateless App Behind a Load Balancer

No matter which app server handles a request, it reaches the same shared session store, cache, files, and database.

┌───────────────┐
        requests ───▶│ Load Balancer │
                      └──────┬────────┘
              ┌──────────────┼──────────────┐
              ▼              ▼              ▼
        ┌──────────┐  ┌──────────┐   ┌──────────┐
        │ App Srv A│  │ App Srv B│   │ App Srv C│   (any one is disposable)
        └────┬─────┘  └────┬─────┘   └────┬─────┘
             └──────────────┼──────────────┘
                             ▼
        ┌─────────────────────────────────────────┐
        │  Shared infra: Redis (sessions + cache)   │
        │  Object storage (uploads) · Database       │
        └─────────────────────────────────────────┘

Important Rules

Never store session data on local disk once more than one application server exists — use Redis or a database-backed handler.

Never write user uploads to local disk in a multi-server deployment — use object storage.

Never rely on a local/in-process cache (like APCu) for anything that must be consistent across requests from the same user.

Design every health-check endpoint to actually verify dependencies, not just that the process is alive.

Treat sticky sessions as a temporary workaround at best, not a substitute for real statelessness.

Why 'It Works Locally' Isn't Enough

Teams frequently discover their app isn't actually stateless only after their first traffic spike forces a scale-out to two or three servers — suddenly a small fraction of logins fail or uploaded avatars vanish, and the root cause is almost always local sessions or local file storage that worked fine on a single server for years.

// Leaving session.save_handler at its default 'files' after adding a second app server session.save_handler = redis session.save_path = "tcp://redis.internal:6379"

File-based sessions are invisible across servers; the moment a second backend joins the pool, users start getting randomly logged out depending on which server answers each request.

move_uploaded_file($tmp, '/var/www/html/uploads/' . $filename);

$s3Client->putObject(['Bucket' => 'my-app-uploads', 'Key' => $filename, 'SourceFile' => $tmp]);

A file saved to local disk on the server that happened to handle the upload request will 404 when a later request for that file is routed to a different backend by the load balancer.

Why does adding a load balancer with multiple app servers force a PHP application to become stateless? A. Load balancers only support stateless protocols like HTTP B. Because any given request can be routed to any backend server, so no server can assume it holds data a later request needs locally C. PHP cannot run behind a load balancer at all without a special extension D. Stateless apps are required by law for PCI compliance

B. Because any given request can be routed to any backend server, so no server can assume it holds data a later request needs locally — A load balancer treats backend servers as interchangeable; since consecutive requests from the same user may hit different servers, no server can rely on local state persisting across requests.

What is the main advantage of horizontal scaling over vertical scaling? A. It's always cheaper per server B. It requires no code changes ever C. It has no hard capacity ceiling and provides fault tolerance, since losing one of many servers doesn't take down the whole app D. It eliminates the need for a database

C. It has no hard capacity ceiling and provides fault tolerance, since losing one of many servers doesn't take down the whole app — Vertical scaling hits a ceiling (the largest available server) and remains a single point of failure; horizontal scaling adds more interchangeable servers, which scales further and survives individual server failures.

What's wrong with using PHP's default file-based session handler in a multi-server deployment? A. Nothing — PHP automatically syncs session files across servers B. Session files are written to one server's local disk, so a request routed to a different server won't find that session, causing random-seeming logouts C. File-based sessions are slower than database sessions but otherwise identical D. File-based sessions only work with Apache, not Nginx

B. Session files are written to one server's local disk, so a request routed to a different server won't find that session, causing random-seeming logouts — Local session files exist only on the server that created them; without shared storage like Redis, other servers in the pool have no way to see that session, so users appear logged out intermittently.

Where should uploaded user files (like avatars) be stored in a horizontally-scaled PHP app? A. On the local disk of whichever server handles the upload B. In the PHP session C. In object storage (e.g. S3/MinIO/Spaces), accessible identically from every application server D. In APCu for fast access

C. In object storage (e.g. S3/MinIO/Spaces), accessible identically from every application server — Object storage is external to any individual application server, so every server can serve or reference an uploaded file regardless of which server originally received the upload.

You're scaling a PHP app from one server to four behind a load balancer. List the three pieces of state currently stored locally on that one server that must move to shared infrastructure before scaling out, and name a suitable shared replacement for each.

1) Sessions (local files) → Redis or a database-backed session handler. 2) In-process/local cache (e.g. APCu) → Redis or Memcached. 3) Uploaded files (local disk) → object storage (S3/MinIO/Spaces), storing only the resulting URL/key in the database.

Think about what session_start(), a local cache function, and a file upload handler each currently touch on disk or in local process memory.