Worker Process Lifecycle and Restart Strategies

Understand why long-running PHP workers are deliberately restarted on a schedule, and learn the max-requests-before-respawn pattern that keeps worker fleets healthy.

You already know how to catch a signal and how a long-running PHP process can leak memory or accumulate stale state over time. This lesson sits at the intersection of those two ideas: it is about the operational policy of *planned, periodic restarts* — deciding in advance that a worker will die after doing a bounded amount of work, so that a supervisor can bring up a clean replacement before anything goes wrong. This is one of the most common patterns you will find in real PHP deployments, from PHP-FPM's pm.max_requests to custom queue-worker loops.

A quick recap: signals and graceful shutdown

The lesson "Process Control and Signals" covers pcntl_signal(), SIGTERM, SIGINT, and the mechanics of catching a signal so a process can finish its current unit of work before exiting instead of being killed mid-task. The lesson "Long-Running PHP Applications" covers why processes that live for hours or days are dangerous in PHP specifically: memory that never gets released because the request-per-process model PHP was designed around no longer resets anything between units of work, plus state contamination where a static property or a cached value from one job silently leaks into the next.

This lesson does not re-derive either of those topics. It assumes you can already write a SIGTERM handler and that you already understand why an 8-hour-old worker process is scarier than a fresh one. What it adds is the *strategy* layer: instead of only reacting to problems (catching a signal when told to stop, or watching memory usage and panicking when it gets too high), you can proactively schedule your own death after a bounded amount of work, on your own terms, before anything goes wrong.

The max-requests-before-respawn pattern

PHP-FPM has shipped this idea for years through the pm.max_requests configuration directive: each FPM child process serves at most N HTTP requests and then exits cleanly, at which point the FPM master process immediately spawns a fresh child to replace it. The number is usually in the thousands, so no single request notices, but over the lifetime of a busy server every worker gets recycled constantly. The same idea, implemented by hand, is the standard way to keep custom CLI workers (queue consumers, cron-triggered daemons, socket servers) healthy.

The shape of a hand-rolled version is always the same: a loop with a counter, a maximum, and a clean exit when the maximum is hit. The supervisor around the script (systemd, Supervisor, Docker's restart policy, a process manager like Horizon) is responsible for noticing the exit and starting a new instance — the PHP script itself never tries to restart itself, it only ever agrees to stop.

• The worker counts iterations (jobs processed, requests served, messages consumed), not wall-clock time — a busy worker and an idle worker hit the limit at different real times, which is fine. • On hitting the limit the worker finishes its *current* unit of work, then exits with status 0 (a clean, expected exit, not a crash). • The exit is deliberately combined with the signal-handling flag pattern from "Process Control and Signals": both a caught SIGTERM and a hit request-limit set the same $shouldStop flag, so the shutdown path is identical either way. • The exit code and any final log line should distinguish a *planned* respawn from a crash, so monitoring does not page someone for routine recycling.

Why this defends against slow leaks

A memory leak in a long-running PHP process is rarely a single large allocation — it is usually a slow accumulation: a static array that grows by one entry per job and is never cleared, a database driver that caches prepared statements without bound, an event listener that gets registered again on every iteration and is never removed. Any of these will eventually exhaust available memory and crash the process, but the timeline is unpredictable and depends on load, so it is very hard to test for in advance.

A bounded lifetime turns an unbounded, unpredictable failure mode into a bounded, predictable one. If a worker leaks roughly 200 KB per job and you cap it at 5,000 jobs before respawn, you know its worst-case memory footprint is roughly 1 GB plus the process baseline — you can size your containers around that number with confidence, even without finding and fixing the leak itself. This does not make the leak acceptable long-term (you should still track it down), but it converts an open-ended risk into a capacity-planning problem you can reason about today.

It also limits the *blast radius* of state contamination. If one job somehow corrupts a static cache or leaves a database connection in a bad transaction state, only the jobs processed by that one worker before its next scheduled respawn are affected, and the fleet self-heals as workers cycle.

Bounded worker loop shape

The loop condition checks both the signal flag and the iteration cap — either one ends the loop the same way. exit(0) signals a normal, planned exit to the supervisor, distinct from a nonzero crash exit code.

<?php

$maxIterations = 5000;
$processed = 0;
$shouldStop = false;

pcntl_signal(SIGTERM, function () use (&$shouldStop) {
    $shouldStop = true;
});

while (!$shouldStop && $processed < $maxIterations) {
    pcntl_signal_dispatch();

    // ... do one unit of work ...

    $processed++;
}

// Clean, expected exit — the supervisor restarts a fresh process.
exit(0);

Code Examples

The worker tracks two independent respawn triggers: a hard job count (the pm.max_requests-style pattern) and a soft memory ceiling checked with memory_get_usage(true). Either one, or an external SIGTERM, ends the loop the same clean way — via a flag checked at the top of each iteration, never by killing the process mid-job. Expected result: Recycling worker after 2000 jobs (planned).

if ($this->hitRespawnLimit()) { ... break; }

<?php
declare(strict_types=1);

final class RespawningWorker
{
    private bool $shouldStop = false;
    private int $processedCount = 0;

    public function __construct(
        private readonly int $maxJobs = 2000,
        private readonly int $maxMemoryBytes = 128 * 1024 * 1024, // 128 MB soft ceiling
    ) {
        pcntl_signal(SIGTERM, [$this, 'onSignal']);
        pcntl_signal(SIGINT, [$this, 'onSignal']);
    }

    public function onSignal(int $signal): void
    {
        $this->shouldStop = true;
    }

    public function run(iterable $jobs): void
    {
        foreach ($jobs as $job) {
            pcntl_signal_dispatch();

            if ($this->shouldStop) {
                break; // graceful: stop taking new work
            }

            $this->handle($job);
            $this->processedCount++;

            if ($this->hitRespawnLimit()) {
                fwrite(STDOUT, "Recycling worker after {$this->processedCount} jobs (planned).\n");
                break;
            }
        }

        exit(0); // supervisor (systemd/Supervisor/Horizon) starts a fresh worker
    }

    private function hitRespawnLimit(): bool
    {
        return $this->processedCount >= $thi
…

Fleet of respawning workers

Each worker dies after a bounded amount of work; the supervisor's only job is to notice the clean exit and start a replacement.

Supervisor (systemd / Supervisor / Horizon)
        |
        |-- spawns --> Worker A  (processes jobs 1..2000) -- exits clean --.
        |                                                                 |
        |<---------------------- respawn signal -------------------------'
        |-- spawns --> Worker A' (fresh memory, processes jobs 2001..4000)
        |-- spawns --> Worker B  (independent lifetime, own counter)

Important Rules

Recycle on a bounded unit of work (job count, request count, or a memory ceiling), never on a fixed wall-clock timer alone — a busy worker and an idle worker have very different risk profiles at the same elapsed time.

Always let the supervisor (not the script itself) be responsible for restarting — a script that tries to re-exec itself duplicates work systemd/Supervisor already does reliably.

Finish the current unit of work before exiting; never abandon a job mid-processing just because the counter ticked over.

Log planned recycles distinctly from crashes so alerting does not treat routine respawns as incidents.

PHP-FPM's pm.max_requests in production

Most PHP-FPM deployments set pm.max_requests to a few thousand specifically because long-lived extensions (image processing, XML parsers, certain database drivers) are known to leak small amounts of memory per request. Rather than auditing every extension for leaks, ops teams accept the leaks exist and simply recycle workers often enough that no single process's memory footprint ever becomes a problem — the same reasoning applies directly to hand-written CLI workers.

Relying only on a memory_limit crash (PHP's fatal "Allowed memory size exhausted" error) to end a worker's life.

Proactively cap the worker with a job count or a soft memory ceiling checked every iteration, so it exits cleanly before hitting the hard memory_limit.

A fatal memory-exhaustion error can happen in the middle of a unit of work, potentially leaving external state (a database transaction, a partially-written file, an acknowledged-but-unprocessed queue message) in a bad condition. A planned exit only happens between jobs, when it's safe.

Why does a bounded worker lifetime (e.g. exit after 2000 jobs) help with slow memory leaks, even without fixing the leak's root cause? A. It makes PHP's garbage collector run more aggressively inside the loop B. It converts an unbounded, unpredictable failure into a bounded, predictable worst-case memory footprint you can plan capacity around C. It prevents memory leaks from ever occurring in PHP D. It disables the memory_limit ini setting so the leak never causes a fatal error

B. It converts an unbounded, unpredictable failure into a bounded, predictable worst-case memory footprint you can plan capacity around — Capping the amount of work a single process does caps how much a per-job leak can accumulate before the process is recycled, turning an open-ended risk into a known worst case — it does not eliminate the underlying leak.

In the max-requests-before-respawn pattern, which component is responsible for actually starting the replacement process? A. The worker script re-executes itself via exec() just before exiting B. PHP's built-in FPM master process, even for custom CLI scripts C. An external supervisor (systemd, Supervisor, Docker restart policy, Horizon, etc.) D. The signal handler that caught SIGTERM

C. An external supervisor (systemd, Supervisor, Docker restart policy, Horizon, etc.) — The worker's only job is to exit cleanly and predictably; a separate supervisor process is responsible for detecting the exit and spawning a fresh replacement.

Modify the RespawningWorker example so that, in addition to a job count and memory ceiling, it also respawns after a maximum wall-clock lifetime (e.g. 1 hour), whichever limit is hit first.

Add private readonly int $startedAt set to time() in the constructor, a $maxLifetimeSeconds parameter, and extend hitRespawnLimit() with || (time() - $this->startedAt) >= $this->maxLifetimeSeconds.

Record a start timestamp in the constructor and compare against time() alongside the existing two checks inside hitRespawnLimit().