Process Control and Signal Handling with pcntl

Use the pcntl extension to register signal handlers for SIGTERM, SIGINT, and SIGHUP, implement graceful shutdown in long-running PHP processes like queue workers, and explain how this relates to process supervision and the Unix process model.

A PHP script running under FPM lives for one request and dies -- signals barely matter there, since the FPM master already handles process lifecycle for you. But a long-running PHP process -- a queue worker consuming jobs in a loop, a daemon, a supervised background service -- is a completely different animal: it needs to know when the outside world wants it to stop, and it needs the chance to finish what it's doing (commit a database transaction, ack a queue message, close a socket) before it actually does. That's exactly what Unix signals and PHP's pcntl extension are for. This lesson covers the Unix process/signal model and how to write PHP processes that shut down gracefully instead of getting killed mid-work.

The Unix Process Model and Signals

Every running program on a Unix-like system (Linux, macOS, BSD) is a process, identified by a process ID (PID), and the operating system provides a small, fixed set of asynchronous notifications called signals that one process (or the kernel itself) can send to another to request some action -- interrupt execution, terminate, reload, pause, or run custom code the target process has registered for that signal. Signals are fundamentally different from function calls or exceptions: they arrive asynchronously, from outside the program's own control flow, at essentially any point during execution, which is exactly why handling them correctly requires deliberate design rather than just "catching them like an exception".

The three signals this lesson focuses on are the ones every long-running PHP process should think about. SIGTERM ("terminate", signal 15) is the polite request to stop -- it's what kill <pid> sends by default, and what process supervisors (systemd, Docker, Kubernetes) send when they want a process to shut down normally, always with an expectation that the process gets a brief window to clean up before a harsher SIGKILL follows if it doesn't. SIGINT ("interrupt", signal 2) is what a terminal sends when a user presses Ctrl+C on a foreground process -- functionally similar to SIGTERM in intent (please stop) but arriving from interactive terminal input rather than another process. SIGHUP ("hangup", signal 1), historically sent when a controlling terminal disconnected, has been repurposed by many long-running daemons as a "reload your configuration without fully restarting" signal -- exactly the convention nginx and php-fpm's master processes follow.

Critically, SIGKILL (signal 9) and SIGSTOP cannot be caught, blocked, or handled by any process -- the kernel terminates (or pauses) the process unconditionally the moment either arrives, with zero opportunity for cleanup code to run. This is precisely why a well-behaved process needs to handle SIGTERM gracefully on its own: SIGKILL exists as the supervisor's last resort specifically because a hung or non-responsive process gives it no other option, and relying on it is guaranteed to skip any cleanup logic entirely.

• SIGTERM (15) -- polite "please stop" request; the default signal sent by kill and process supervisors. • SIGINT (2) -- sent by Ctrl+C in a terminal; similar intent to SIGTERM but from interactive input. • SIGHUP (1) -- historically "terminal disconnected"; commonly repurposed by daemons as "reload config". • SIGKILL (9) and SIGSTOP -- cannot be caught or handled; the kernel acts unconditionally, no cleanup possible.

The pcntl Extension: Registering Signal Handlers

PHP's pcntl ("Process Control") extension exposes Unix process and signal facilities to PHP scripts -- it's not available on Windows and is not loaded by default in every PHP build, so a real script should check function_exists('pcntl_signal') before relying on it (or fail clearly if it's required and missing). The central function is pcntl_signal(int $signal, callable $handler), which registers a callback to run whenever the given signal arrives -- for example pcntl_signal(SIGTERM, function (int $signal) { ... }) makes that closure run when the process receives SIGTERM instead of the default action (which, for SIGTERM, is immediate termination).

Signal handlers in PHP are, by default, only actually invoked when the engine hits a designated "dispatch" point -- historically only inside certain blocking calls, but since PHP 7.1 you can enable asynchronous signal handling with declare(ticks=1) (older, slower) or the simpler pcntl_async_signals(true) (the modern, recommended approach), which tells PHP to check for and dispatch pending signals as soon as they arrive, without needing to wait for a tick boundary or a blocking syscall to return. Forgetting to call pcntl_async_signals(true) is one of the most common reasons a registered signal handler appears to simply never fire.

The standard pattern for a gracefully-shutdownable long-running process is a boolean flag set inside the signal handler and checked at the top of the main loop -- never do the actual cleanup work inside the handler itself if it's non-trivial, since signal handlers can interrupt the program at arbitrary points and doing heavy work there risks reentrancy issues; instead, the handler just flips a flag, and the main loop notices that flag at a safe point (between jobs, not mid-transaction) and exits cleanly from there.

• pcntl_signal(SIGTERM, $handler) -- registers a callback to run when that signal is received. • pcntl_async_signals(true) -- modern way to enable signal dispatch as soon as a signal arrives (PHP 7.1+); without it, handlers may not fire promptly. • declare(ticks=1) -- older mechanism for the same purpose, generally superseded by pcntl_async_signals(). • Best pattern: handler sets a flag; the main loop checks the flag at a safe point and exits cleanly -- avoid heavy work directly inside the handler.

Graceful Shutdown in Worker Processes and Queue Workers

A queue worker is the textbook real-world use case for everything in this lesson: it runs an infinite loop, pulling jobs off a queue (Redis, RabbitMQ, SQS, a database table), processing each one, and acknowledging completion, forever, until told to stop. Without signal handling, a deploy or a supervisor restart sends SIGTERM, and by default PHP terminates the process immediately -- potentially mid-job, with a job half-processed, a database transaction left open, or a queue message neither acknowledged nor properly returned to the queue, all of which can mean duplicated work, lost work, or corrupted state depending on the queue's semantics.

With pcntl_signal(SIGTERM, ...) registered and pcntl_async_signals(true) enabled, the same worker instead sets a $shouldStop flag when SIGTERM arrives, finishes the job it's currently processing (acknowledging it normally), and only then exits its main loop -- the queue message is ack'd or nack'd correctly, any open transaction is committed or rolled back deliberately, and the process exits with a clean status rather than being cut off arbitrarily. This is exactly the graceful-shutdown pattern process supervisors are designed to expect: they send SIGTERM, wait a configured grace period (Kubernetes's default terminationGracePeriodSeconds is 30 seconds, systemd has its own TimeoutStopSec), and only escalate to SIGKILL if the process hasn't exited on its own by then.

Process supervision -- the practice of a parent process (systemd, Supervisor, Docker/Kubernetes, or a hand-rolled master/worker setup) restarting a worker process automatically if it dies unexpectedly -- relies on exactly this contract: a well-behaved worker responds to SIGTERM by shutting down cleanly within the grace period, while the supervisor's job is purely to notice unexpected exits and restart them. This mirrors the FPM master/worker model from earlier in this course almost exactly, except here you as the application author are responsible for writing the signal-handling logic that FPM's master process already provides for you when running plain HTTP requests.

• Queue workers are the classic use case: an infinite job-processing loop that must not be killed mid-job. • Correct pattern: SIGTERM sets a flag -> finish current job -> ack/commit cleanly -> exit loop -> process ends with clean status. • Process supervisors (systemd, Kubernetes, Supervisor) send SIGTERM, wait a grace period, then escalate to SIGKILL if the process hasn't exited. • This is the same master/worker contract as PHP-FPM, but here the application author writes the graceful-shutdown logic instead of FPM providing it automatically.

Registering Graceful-Shutdown Signal Handlers

pcntl_async_signals(true) is the PHP 7.1+ recommended replacement for the older declare(ticks=1) mechanism. The handler closure captures $shouldStop by reference (use (&$shouldStop)) so it can flip the flag the main loop is watching. Checking function_exists('pcntl_signal') first is important -- pcntl is not compiled into every PHP build and is unavailable on Windows.

<?php
declare(strict_types=1);

if (!function_exists('pcntl_signal')) {
    fwrite(STDERR, "pcntl extension is required for this worker." . PHP_EOL);
    exit(1);
}

// Enable async signal dispatch -- without this, handlers may not
// fire until the next "tick" or blocking call, which can be too late.
pcntl_async_signals(true);

$shouldStop = false;

$handler = function (int $signal) use (&$shouldStop) {
    fwrite(STDERR, "Received signal {$signal}, shutting down gracefully..." . PHP_EOL);
    $shouldStop = true; // never do heavy cleanup directly in the handler
};

pcntl_signal(SIGTERM, $handler); // supervisor / kill's default request to stop
pcntl_signal(SIGINT, $handler);  // Ctrl+C
pcntl_signal(SIGHUP, $handler);  // often "reload", handled here as "stop" for simplicity

Code Examples

This is the canonical graceful-shutdown worker pattern: the signal handlers only ever flip $shouldStop, never touch job state directly, and the while loop's condition is checked exactly once per iteration -- between jobs, never mid-job -- so a SIGTERM that arrives while processJob() is running doesn't interrupt that job; it's simply noticed the next time the loop condition is evaluated, after that job has already been fully acknowledged. Expected result: [worker] started, PID=48213 [worker] processing job-1... [worker] SIGTERM received -- finishing current job, then stopping. [worker] job-1 done, acknowledged. [worker] shutdown complete, exiting cleanly. Notice in the sample output that the SIGTERM log line appears in the middle of processing job-1 -- pcntl_async_signals(true) dispatches the handler the instant the signal arrives, mid-sleep(1) -- but the job still finishes and gets acknowledged before the loop actually exits.

while (!$shouldStop) {... processJob($job);... } extension_loaded('pcntl') is a defensive check every real CLI worker script should include, since pcntl is not available on Windows and is not compiled into every Unix PHP build either. getmypid() returns the current process's own PID -- exactly the number another process (or a human at a terminal) would target with kill -TERM <pid> to request graceful shutdown, versus kill -KILL <pid> to bypass all handling entirely. Expected result: Running as PID 48213. Send a graceful stop with: kill -TERM 48213 Send an immediate, unhandleable kill with: kill -KILL 48213

<?php
declare(strict_types=1);

pcntl_async_signals(true);

$shouldStop = false;
pcntl_signal(SIGTERM, function (int $signal) use (&$shouldStop) {
    fwrite(STDERR, "[worker] SIGTERM received -- finishing current job, then stopping." . PHP_EOL);
    $shouldStop = true;
});
pcntl_signal(SIGINT, function (int $signal) use (&$shouldStop) {
    fwrite(STDERR, "[worker] SIGINT (Ctrl+C) received -- finishing current job, then stopping." . PHP_EOL);
    $shouldStop = true;
});

function fetchNextJob(): ?string {
    // Stand-in for popping a job off Redis/RabbitMQ/a DB table.
    static $jobs = ['job-1', 'job-2', 'job-3'];
    return array_shift($jobs);
}

function processJob(string $job): void {
    fwrite(STDOUT, "[worker] processing {$job}..." . PHP_EOL);
    sleep(1); // stand-in for real work
    fwrite(STDOUT, "[worker] {$job} done, acknowledged." . PHP_EOL);
}

fwrite(STDOUT, "[worker] started, PID=" . getmypid() . PHP_EOL);

while (!$shouldStop) {
    $job = fetchNextJob();

    if ($job === null) {
        sleep(1); // no work available right now -- poll again shortly
        continue;
    }

    processJob($job); // finishes fully even if SIGTERM arrives mid-job,
              
…
<?php
declare(strict_types=1);

// pcntl is a Unix-only, optionally-compiled extension.
if (!extension_loaded('pcntl')) {
    fwrite(STDERR, "This script requires the pcntl extension (not available on Windows)." . PHP_EOL);
    exit(1);
}

$pid = getmypid();
fwrite(STDOUT, "Running as PID {$pid}." . PHP_EOL);
fwrite(STDOUT, "Send a graceful stop with: kill -TERM {$pid}" . PHP_EOL);
fwrite(STDOUT, "Send an immediate, unhandleable kill with: kill -KILL {$pid}" . PHP_EOL);

Signal Delivery to a Long-Running Worker

The handler only sets a flag; the main loop decides the safe moment to actually stop.

systemd / Kubernetes / Docker / a human at a terminal
              |
              |  kill -TERM <pid>   (or Ctrl+C -> SIGINT)
              v
     +---------------------------+
     |  PHP worker process       |
     |  pcntl_async_signals(true)|
     |  pcntl_signal(SIGTERM,    |
     |     handler)              |
     +---------------------------+
              |
     handler runs: sets $shouldStop = true
              |
     main loop finishes the CURRENT job,
     then checks $shouldStop -> exits loop
              |
              v
     process exits cleanly (exit code 0)
     within the supervisor's grace period

  (if it does NOT exit in time, supervisor sends SIGKILL -- unhandleable)

Important Rules

Never do heavy or unsafe work directly inside a signal handler -- set a flag and let the main loop act on it at a safe point.

Always call pcntl_async_signals(true) (PHP 7.1+) so registered handlers actually fire promptly.

Handle SIGTERM (and usually SIGINT) in any long-running worker process -- never rely on the default 'die immediately' behavior for graceful shutdown.

Check function_exists('pcntl_signal') / extension_loaded('pcntl') before relying on it -- it's Unix-only and not always compiled in.

Remember SIGKILL and SIGSTOP cannot be caught -- design for graceful shutdown to happen well within a supervisor's grace period, since SIGKILL is always the fallback.

Why This Matters in Production

Every rolling deployment of a containerized PHP queue-worker fleet -- Kubernetes rolling update, Docker Swarm service update, an ECS task replacement -- works by sending SIGTERM to the old containers and waiting a grace period before force-killing them, which means a worker without graceful SIGTERM handling loses in-flight jobs on every single deploy, not as a rare edge case but as guaranteed, routine behavior. This is precisely why frameworks with built-in queue systems (Laravel's queue:work, Symfony Messenger's consume command) implement exactly the pattern shown in this lesson internally, and why understanding it directly matters even when using a framework -- tuning the grace period, understanding what "finishes the current job" actually means for a specific job's atomicity, and diagnosing a deploy that's silently dropping jobs all require this mental model.

pcntl_signal(SIGTERM, function () { /* commit transaction, close connections, etc. */ }); // heavy cleanup directly in the handler, and no pcntl_async_signals(true) call

Register a lightweight handler that only sets a flag, call pcntl_async_signals(true), and do the actual cleanup in the main loop once it notices the flag at a safe point

Without pcntl_async_signals(true), the handler may not run until PHP happens to hit a dispatch point, making shutdown unpredictably delayed; and doing heavy work inside the handler itself risks running cleanup logic at an unsafe, arbitrary point in execution rather than at a moment the program's own logic has verified is safe.

Assuming kill <pid> (or a supervisor's stop command) will always let cleanup code run

Design for graceful shutdown to complete comfortably inside the supervisor's grace period, because after that period a SIGKILL follows, and SIGKILL cannot be caught or handled at all

Every process supervisor's contract is: SIGTERM first, then wait a bounded grace period, then SIGKILL as an unconditional fallback -- a worker that takes too long to notice the flag or finish its current unit of work simply gets killed with zero chance to clean up, so the safe-checkpoint interval (e.g. per-job, not per-batch-of-1000-jobs) matters as much as having a handler at all.

[multiple-choice] Which signal cannot be caught or handled by a process under any circumstances? A. SIGTERM B. SIGINT C. SIGHUP D. SIGKILL

D. SIGKILL — SIGKILL (and SIGSTOP) are handled unconditionally by the kernel -- no process can register a handler for them or block them, which is exactly why they're a supervisor's last-resort fallback after SIGTERM fails to stop a process in time.

[multiple-choice] What does pcntl_async_signals(true) do? A. It makes signal handlers run in a separate thread B. It enables PHP to check for and dispatch pending signals as soon as they arrive, rather than waiting for a tick boundary or a blocking call to return C. It blocks all signals from being delivered to the process D. It automatically restarts the process when a signal is received

B. It enables PHP to check for and dispatch pending signals as soon as they arrive, rather than waiting for a tick boundary or a blocking call to return — Introduced in PHP 7.1 as the modern replacement for declare(ticks=1), pcntl_async_signals(true) tells PHP to dispatch registered signal handlers promptly as signals arrive, instead of only at certain dispatch points.

[true-false] The recommended pattern for a signal handler in a queue worker is to perform all necessary cleanup (closing connections, committing transactions) directly inside the handler function itself. A. True B. False

B. False — False -- the recommended pattern is for the handler to only set a flag; the main loop then checks that flag at a safe point (e.g. between jobs) and performs cleanup there, avoiding the reentrancy risks of doing heavy work inside a handler that can interrupt execution at an arbitrary point.

[multiple-choice] How does process supervision (systemd, Kubernetes, etc.) typically stop a long-running worker process? A. It always sends SIGKILL immediately with no grace period B. It sends SIGTERM, waits a configured grace period for the process to exit on its own, and only sends SIGKILL if it hasn't exited by then C. It sends SIGHUP and expects the process to restart itself D. It deletes the process's PID file, which causes the OS to stop the process automatically

B. It sends SIGTERM, waits a configured grace period for the process to exit on its own, and only sends SIGKILL if it hasn't exited by then — The standard contract is SIGTERM first (a polite request the process can handle gracefully), followed by a bounded grace period, with SIGKILL as the unconditional fallback if the process hasn't exited by the time that period elapses.

Extend the graceful-shutdown worker example so that receiving a second SIGTERM (after the first one has already started a graceful shutdown) forces an immediate exit instead of waiting for the current job to finish -- a common 'I really mean it, stop now' escape hatch.

$shouldStop = false;\n$handler = function (int $signal) use (&$shouldStop) {\n if ($shouldStop) {\n fwrite(STDERR, "Second signal received -- forcing immediate exit.\\n");\n exit(1);\n }\n fwrite(STDERR, "Shutting down gracefully -- send the signal again to force quit.\\n");\n $shouldStop = true;\n};\npcntl_signal(SIGTERM, $handler);\npcntl_signal(SIGINT, $handler);

Track whether $shouldStop is already true when the handler runs again; if so, call exit() directly from inside the handler for that second signal.