Long-Running PHP Applications — Workers, Memory Leaks, and Alternative Runtimes
Understand what changes when PHP code runs inside a long-lived process (a queue worker, a Swoole/RoadRunner/FrankenPHP server) instead of a fresh PHP-FPM process per request: memory leaks that a short-lived request would never surface, state contamination between successive 'requests' handled by the same process, connection reuse and its hazards, graceful shutdown and signal handling, periodic restart as a mitigation, and a practical overview of RoadRunner, Swoole/OpenSwoole, and FrankenPHP.
Classic PHP-FPM is built on a comforting guarantee: every request gets a clean slate. The process (or thread) that handles your request boots, runs your code exactly once, and then every global, every static property, every object your code created is thrown away before the next request is handled -- either by the same worker process or a different one. That guarantee is precisely what a growing number of modern PHP deployments give up in exchange for speed: queue workers that loop forever processing jobs, and application servers like Swoole, RoadRunner, and FrankenPHP that keep the bootstrapped application alive in memory across thousands of requests instead of re-bootstrapping it every time. The performance win is real and often dramatic. But it resurfaces an entire category of bugs -- memory leaks, state leaking between unrelated units of work, stale connections -- that traditional per-request PHP made structurally impossible simply by tearing everything down constantly. This lesson is about that trade-off and how to manage it safely.
Why PHP-FPM's Teardown-Every-Request Model Made Certain Bugs Impossible
In the traditional PHP-FPM model, a worker process boots, bootstraps your framework, handles exactly one HTTP request, and then either exits (if pm.max_requests is reached) or simply discards every piece of memory state associated with that request before picking up the next one -- all global state, all static properties, every object graph your code built, gone. The operating system and the Zend Memory Manager reclaim it wholesale. This means a memory leak within a single request -- an array that grows without bound, a listener that's never removed -- essentially cannot matter: however much memory a request leaked, it is completely reclaimed the moment that request finishes, because nothing survives to the next one.
A long-running process -- a queue worker consuming jobs in a while (true) loop, or an application server that keeps your bootstrapped app object alive to handle the next HTTP request without re-bootstrapping -- breaks that guarantee on purpose, because re-bootstrapping a framework (autoloading, building a DI container, connecting to a database) on every single unit of work is exactly the overhead these architectures exist to eliminate. The cost is that the SAME PHP process, the same memory space, now survives across potentially millions of 'requests' (HTTP requests or queue jobs), so anything that isn't explicitly cleaned up between iterations accumulates instead of being thrown away.
• PHP-FPM: one process, one request, then total memory teardown -- leaks within a request are automatically bounded by the request's own lifetime. • Long-running worker/server: one process, MANY requests/jobs, memory persists across all of them -- nothing is torn down unless code does it explicitly. • The performance win of staying warm (no re-bootstrap per request) is exactly what removes the safety net that made leaks harmless before.
Memory Leaks in Long-Running Processes
A 'leak' here doesn't usually mean PHP's garbage collector is broken -- PHP's cycle collector does reclaim circular references, just not necessarily immediately. It means application code is holding references to things it no longer needs, and those references live in structures that persist across loop iterations: a cache array that only ever grows ($this->cache[$key] = $value; with no eviction), event listeners registered fresh on every iteration and never removed, a static property accumulating log entries or debug data 'just in case', or large object graphs captured by a long-lived closure that outlives the objects it should have let go of.
The practical defense is twofold. First, be deliberate about anything that persists between iterations: any cache, buffer, or registry that a worker builds up needs an explicit eviction or reset strategy, not just an assumption that 'it'll be small.' Second, treat memory monitoring as a first-class part of the worker loop itself -- checking memory_get_usage() periodically and either logging a warning or proactively exiting past a threshold is standard practice, precisely because even careful code can leak in ways that are hard to predict from source alone (a third-party library's internal cache, for instance), and a monitored self-restart is a far better failure mode than an OOM-killed process taking a job down mid-execution.
• Unbounded caches/registries that only ever grow are the most common leak source in worker code. • Event listeners or callbacks registered per-iteration and never unregistered accumulate silently. • Closures that capture large object graphs by reference keep those graphs alive far longer than intended. • Monitor memory_get_usage() in the loop itself and restart proactively past a threshold -- don't rely on leak-free code alone.
State Contamination Between 'Requests' in a Long-Running Process
This is the subtlest and most dangerous class of long-running-process bug: an object instance that is reused across many jobs (common, since building it fresh each time is exactly the overhead you're avoiding) can carry mutable state set during job #1 forward into job #2, if nothing explicitly resets it. Consider a service object with an instance property like $this->currentUserId, set at the start of handling a request and read later in the same handler -- in PHP-FPM, a fresh instance (or at minimum a fresh worker process) for the next request makes this safe by accident. In a Swoole-style server that keeps the same container and the same service instances alive across HTTP requests, the SAME object instance can still have last request's $currentUserId sitting in it if the code path that's supposed to set it for THIS request never runs -- a serious correctness and potential security bug, not just a performance annoyance.
The fix is architectural discipline, not a single line of code: anything that is logically 'per-request' state must either be constructed fresh for every request/job (accepting the small object-creation cost, which is usually negligible compared to the bootstrap cost being avoided), or explicitly reset at the start (or end) of every iteration through a dedicated method the framework calls automatically. Frameworks built for these runtimes (Laravel Octane, for instance) document exactly which of their services are safe to share across requests and which must be re-resolved per request for this reason -- treat that documentation as load-bearing, not optional reading.
• A reused object instance can carry mutable state from job N into job N+1 if nothing resets it. • This is a correctness/security bug class specific to long-running processes -- it cannot happen in a fresh-process-per-request model. • Fix: construct request-scoped state fresh per iteration, or explicitly reset it at a well-defined point every iteration. • Frameworks targeting these runtimes document which services are safe to share vs must be per-request -- follow that guidance precisely.
Connection Reuse: Database and Redis Connections That Persist
One of the genuine benefits of a long-running process is that a database or Redis connection, once opened, can be reused across every subsequent job or request instead of paying a fresh TCP handshake plus authentication on every single one -- a real, measurable latency win at scale. The hazard is that a connection opened once and reused for hours needs to be treated as something that can go stale: the database server may close idle connections after a timeout, a network blip can silently sever the TCP connection, and code that blindly reuses a dead connection object will fail in confusing ways rather than a clean 'connection refused'. Production code paths that reuse connections should detect a dead connection (many drivers throw a specific exception class for this) and transparently reconnect, rather than assuming the connection handed to them is always live.
A second, easier-to-miss hazard: a job that begins a database transaction and then throws or exits WITHOUT committing or rolling back leaves the SHARED, reused connection sitting in an open-transaction state -- and the next job that reuses that same connection inherits that half-finished transaction, silently corrupting its own writes. Every job boundary in a long-running worker must guarantee there is no open transaction left behind -- typically by wrapping job execution in a try/finally that rolls back any transaction still open when the job ends, successfully or not.
• Reusing DB/Redis connections across jobs avoids per-job handshake overhead -- a real perf win of long-running processes. • A long-lived connection can go stale (server timeout, network blip); detect and reconnect rather than assuming it's always live. • An uncommitted transaction left open by a failed job poisons the NEXT job on that shared connection -- always roll back any open transaction at job end, success or failure.
Graceful Shutdown, Signal Handling, and Periodic Restarts
Recapping the mechanics: registering pcntl_async_signals(true) plus pcntl_signal(SIGTERM, $handler) lets a worker loop respond to a SIGTERM (the signal Kubernetes and most process managers send before forcibly killing a process during a deploy or scale-down) by setting a flag rather than dying mid-job. The loop itself checks that flag BETWEEN jobs, not inside one -- finishing whatever job is currently in flight before exiting cleanly -- because killing a job halfway through is exactly the kind of partial-state problem a graceful shutdown exists to avoid. Orchestrators typically give a grace period (Kubernetes defaults to 30 seconds) between SIGTERM and a forceful SIGKILL, so the in-flight job needs to actually finish within that window or the SIGKILL will cut it off anyway.
Because eliminating every possible leak or state-contamination bug in application and third-party library code is unrealistic, production worker deployments commonly bound the blast radius with periodic restarts: configure the worker to process N jobs (or run for T minutes) and then exit cleanly on its own, letting the process supervisor (Supervisor, systemd, Kubernetes) immediately start a fresh replacement. This is the manual, worker-loop equivalent of PHP-FPM's pm.max_requests setting, and it turns 'this specific worker has been slowly leaking for six hours' into 'this worker restarts every 30 minutes regardless, so any leak is bounded by 30 minutes of accumulation, no matter how it happened.'
• pcntl_async_signals(true) + pcntl_signal(SIGTERM, ...) -- set a flag on signal, don't act inside the handler itself. • Check the shutdown flag BETWEEN jobs, finishing the current job before exiting, within the orchestrator's grace period. • Periodic restart (N jobs or T minutes, then clean exit) bounds the impact of any leak or state bug, known or unknown.
Long-Running PHP Runtimes: RoadRunner, Swoole/OpenSwoole, and FrankenPHP
RoadRunner is a Go-based application server that speaks a simple RPC-style protocol to a pool of long-lived PHP worker processes it manages -- the Go process handles raw networking and process supervision, while each PHP worker boots your application ONCE and then loops, handling one request per RPC call without re-bootstrapping. Swoole (and its actively maintained fork, OpenSwoole) takes a different approach: it's a C extension that turns the PHP process itself into an event-driven, coroutine-capable server, so PHP code directly owns the event loop and can use lightweight coroutines for concurrent I/O within a single process, going well beyond just 'stay warm between requests'. FrankenPHP is a Go-based server built on the Caddy web server, embedding the PHP interpreter directly via cgo; it supports both a classic mode (closer to traditional per-request PHP, but without a separate Nginx+PHP-FPM setup) and an explicit worker mode that keeps the application booted between requests the same way RoadRunner does.
All three exist to eliminate the same overhead -- re-bootstrapping a framework, its DI container, and its class autoloading on every single request -- and all three, precisely because of that, inherit every hazard described earlier in this lesson: memory leaks accumulate, request-scoped state must be explicitly managed, and connections must be reused carefully. 'Persistent application state considerations' is not a footnote specific to one of these three runtimes -- it is the central design trade-off common to all of them, and any framework, library, or piece of legacy code written with an implicit assumption of 'this class is only ever used for one request' needs to be specifically audited before it's trusted under any of them.
• RoadRunner -- Go server + a managed pool of long-lived PHP workers speaking RPC; app boots once per worker. • Swoole/OpenSwoole -- C extension turning the PHP process itself into an event-driven, coroutine-capable server. • FrankenPHP -- Go/Caddy-based server embedding PHP directly, with an explicit persistent worker mode. • All three share the same persistent-state hazards described earlier -- the runtime choice doesn't remove the need to audit for them.
Code Examples
In OrderProcessorBuggy, $currentUser is an instance property -- exactly the kind of state that persists across process() calls when the SAME $buggy instance is reused for every job in a worker loop (which it will be, since constructing it fresh per job would defeat the point of a long-running process). The second call has no 'user' key in its job payload, so the property is simply never overwritten and still holds alice's data from the first call. OrderProcessorFixed eliminates the entire bug class by never storing request-scoped data in a property at all -- it's a local variable, scoped to the single process() call, that cannot outlive it. Expected result: Receipt sent to: alice@example.com Receipt sent to: alice@example.com <-- BUG: should be UNKNOWN Receipt sent to: alice@example.com Receipt sent to: UNKNOWN <-- correct private ?array $currentUser = null; // property state surviving across calls
Common mistake: Storing anything that's logically 'per job' or 'per request' as an instance property on a service object that the worker loop reuses across many jobs, instead of as a local variable or an explicitly-passed parameter.
The signal handler only ever sets a flag -- it never tries to abort the current job mid-flight -- and the flag is checked at the TOP of the loop, meaning any job already in progress when SIGTERM arrives runs to completion before the loop exits. The MAX_JOBS_BEFORE_RESTART check gives the same clean-exit behavior proactively, bounding how long any single process can accumulate leaked memory or subtle state issues, regardless of whether a shutdown signal ever arrives.
pcntl_signal(SIGTERM, function () use (&$shouldStop) { $shouldStop = true; });
<?php
declare(strict_types=1);
// WRONG: $currentUser is set once per job but this SAME instance is reused
// across every job in the worker loop -- if a later job's code path never
// sets it (an early-return branch, say), it silently reads the PREVIOUS
// job's user.
final class OrderProcessorBuggy
{
private ?array $currentUser = null;
public function process(array $job): void
{
if (isset($job['user'])) {
$this->currentUser = $job['user'];
}
// If this job has no 'user' key, $this->currentUser still holds
// whatever the PREVIOUS job set it to.
$this->sendReceipt($this->currentUser);
}
private function sendReceipt(?array $user): void
{
echo 'Receipt sent to: ' . ($user['email'] ?? 'UNKNOWN') . PHP_EOL;
}
}
// RIGHT: request-scoped state is a local variable, never a property that
// survives between process() calls -- there is nothing left to contaminate.
final class OrderProcessorFixed
{
public function process(array $job): void
{
$currentUser = $job['user'] ?? null;
$this->sendReceipt($currentUser);
}
private function sendReceipt(?array $user): void
{
…<?php
declare(strict_types=1);
pcntl_async_signals(true);
$shouldStop = false;
$jobsProcessed = 0;
const MAX_JOBS_BEFORE_RESTART = 500;
pcntl_signal(SIGTERM, function () use (&$shouldStop) {
fwrite(STDOUT, "SIGTERM received -- finishing current job, then exiting\n");
$shouldStop = true;
});
pcntl_signal(SIGINT, function () use (&$shouldStop) {
$shouldStop = true;
});
while (!$shouldStop) {
$job = fetchNextJobFromQueue(); // blocks briefly, or returns null if empty
if ($job === null) {
usleep(200_000);
continue;
}
try {
processJob($job);
} catch (\Throwable $e) {
fwrite(STDERR, 'Job failed: ' . $e->getMessage() . PHP_EOL);
}
$jobsProcessed++;
// Periodic restart: bound the blast radius of any leak or state bug,
// known or unknown, by exiting cleanly after N jobs.
if ($jobsProcessed >= MAX_JOBS_BEFORE_RESTART) {
fwrite(STDOUT, "Reached job limit -- exiting for a clean restart\n");
break;
}
}
fwrite(STDOUT, "Worker shut down cleanly after {$jobsProcessed} jobs\n");Traditional PHP-FPM vs a long-running worker process
PHP-FPM gets a clean slate for free, every time. A long-running worker only gets one at process start -- everything after that is the application's own responsibility to keep clean, which is exactly what this lesson's memory-leak, state-contamination, and periodic-restart sections are about.
PHP-FPM (per request) Long-running worker / server
---------------------- ----------------------------
Request arrives Process starts ONCE
| |
v v
Boot framework, DI container, Boot framework, DI container,
autoload classes (every request) autoload classes (ONCE)
| |
v v
Handle the one request while (running) {
| handle next job/request
v reset request-scoped state <-- must be explicit
Process exits OR is fully }
reset for next request |
| v (periodically)
v clean exit -> supervisor
ALL memory reclaimed restarts it fresh
(guaranteed clean slate) (bounds any accumulated leak)Important Rules
Never store per-request or per-job data in a property on an object the worker loop reuses -- use local variables or explicit parameters instead.
Monitor memory_get_usage() inside the loop and exit proactively past a threshold rather than waiting for an OOM kill.
Detect and transparently reconnect on a stale database/Redis connection rather than assuming a long-lived connection is always live.
Guarantee no transaction is left open on a shared connection at the end of every job, success or failure (wrap in try/finally).
Check a shutdown flag BETWEEN jobs, never inside one -- let the current unit of work finish within the orchestrator's grace period.
Configure a periodic restart (N jobs or T minutes) as a safety net, even in code believed to be leak-free.
Why This Matters in Production
Queue-worker memory leaks are one of the most common causes of a specific, confusing production pattern: a worker fleet that runs fine for hours and then starts throwing 'Allowed memory size exhausted' fatals in a rolling wave, worker by worker, roughly N hours after the last deploy -- exactly the signature of a slow per-job leak finally crossing the memory_limit on each process in turn. Framework-level worker managers (Laravel Horizon's --max-jobs, Symfony Messenger's consume --limit-memory) exist specifically to encode the periodic-restart mitigation from this lesson as a first-class, configurable setting, precisely because relying on leak-free code alone has proven, repeatedly, not to be sufficient in practice.
// signal handler doing the actual cleanup work directly, mid-signal pcntl_signal(SIGTERM, function () { closeDatabaseConnection(); exit(0); });
pcntl_signal(SIGTERM, function () use (&$shouldStop) { $shouldStop = true; }); // ...loop checks $shouldStop between jobs, THEN closes the connection after the loop
Doing real cleanup work or exiting directly inside the signal handler can interrupt whatever the process was doing at that exact instant -- including a half-finished database write. Setting a flag and letting the main loop notice it between jobs guarantees cleanup happens at a safe, predictable point, not at an arbitrary instant.
[multiple-choice] Why does a memory leak matter far more in a long-running worker than in traditional PHP-FPM? A. It doesn't -- memory leaks behave identically in both models B. PHP-FPM tears down all memory after every request, bounding any leak's lifetime; a long-running process keeps the same memory space across potentially millions of jobs, so leaks accumulate C. Long-running processes don't have a garbage collector at all D. PHP-FPM leaks memory faster than long-running processes
B. PHP-FPM tears down all memory after every request, bounding any leak's lifetime; a long-running process keeps the same memory space across potentially millions of jobs, so leaks accumulate — PHP-FPM's per-request teardown reclaims everything regardless of what leaked, bounding the leak's impact to one request's lifetime. A long-running process shares the same memory space across every job it processes, so anything not explicitly cleaned up accumulates across all of them.
[multiple-choice] What should a SIGTERM signal handler in a worker loop actually do? A. Immediately call exit() to shut down as fast as possible B. Set a flag that the main loop checks between jobs, letting any in-flight job finish first C. Ignore the signal entirely D. Start a new database transaction
B. Set a flag that the main loop checks between jobs, letting any in-flight job finish first — The handler should only set a flag; the loop checks it between jobs so the currently in-flight job (if any) finishes cleanly before the process exits, within the orchestrator's grace period before a forceful SIGKILL.
[multiple-choice] What do RoadRunner, Swoole/OpenSwoole, and FrankenPHP have in common with respect to this lesson's hazards? A. None of them are affected -- they each solve the persistent-state problem automatically B. They all keep the bootstrapped application alive across requests, so they all inherit the same memory-leak and state-contamination hazards described in this lesson C. Only Swoole is affected; RoadRunner and FrankenPHP behave exactly like PHP-FPM D. They eliminate the need for database connection management
B. They all keep the bootstrapped application alive across requests, so they all inherit the same memory-leak and state-contamination hazards described in this lesson — All three exist specifically to avoid re-bootstrapping the application on every request, which is exactly what makes them share the same persistent-state hazards -- memory leaks, state contamination between requests, and connection staleness -- regardless of their different underlying architectures.
A team migrates a queue worker from a fresh-PHP-per-job model (each job invoked as php process-job.php <job-id>) to a long-running worker loop for performance. Two weeks later, users report that some orders are occasionally being shipped to the WRONG customer's address. List the categories of bug from this lesson you'd investigate first, and why this particular symptom points there.
This is the signature of state contamination between jobs: an object instance (perhaps an order-shipping service, a customer-context object, or a DI-resolved 'current customer' service) that used to be constructed fresh for every php process-job.php invocation is now being reused across many jobs in the same long-running process, and something in it -- likely an instance property holding address/customer data -- is not being reset or is being read before it's set on some job. The fix is to audit every service touched during job processing for state stored in properties rather than local variables/parameters, and either make those services request-scoped (constructed fresh per job) or add explicit reset logic at the start of every job.
The symptom is data from one unit of work appearing in another -- which category of bug in this lesson produces exactly that signature?