raw Software

A long-running PHP process is useful for queue consumers, scheduled maintenance, event listeners, and other work that should not be tied to an HTTP request. The PHP code itself is only one part of the service. Something also has to start it after boot, restart it after a crash, collect its output, apply resource limits, and stop it cleanly.

On a current Linux system, the most reliable design is a foreground PHP worker managed by a service supervisor such as systemd. The worker does not fork, create a PID file, close its output streams, or attempt to restart itself. Keeping those responsibilities outside PHP makes failures visible and removes several race conditions from the application.

Requirements

The example needs PHP CLI and the PCNTL extension. PCNTL is intended for Unix-like systems and is not available on Windows. Check the runtime that the service will use:

php --version
php -m | grep -E '^(pcntl|posix)$'

The foreground worker only requires PCNTL for signal handling. The POSIX extension is needed only for the optional self-daemonizing implementation discussed later.

A Foreground PHP Worker

Save the following program as /srv/php-worker/worker.php:

#!/usr/bin/env php
<?php

declare(strict_types=1);

if (PHP_SAPI !== 'cli') {
    fwrite(STDERR, "This program requires PHP CLI.\n");
    exit(64);
}

if (!extension_loaded('pcntl')) {
    fwrite(STDERR, "The PCNTL extension is required.\n");
    exit(69);
}

$running = true;
$reloadRequested = false;
$shutdownSignal = null;
$runOnce = in_array('--once', $argv, true);

pcntl_async_signals(true);

$stop = static function (int $signal) use (&$running, &$shutdownSignal): void {
    $shutdownSignal = $signal;
    $running = false;
};

pcntl_signal(SIGTERM, $stop);
pcntl_signal(SIGINT, $stop);
pcntl_signal(SIGHUP, static function () use (&$reloadRequested): void {
    $reloadRequested = true;
});

if (function_exists('cli_set_process_title')) {
    cli_set_process_title('php-example-worker');
}

fwrite(STDOUT, "Worker started with PID " . getmypid() . ".\n");

while ($running) {
    if ($reloadRequested) {
        reloadConfiguration();
        $reloadRequested = false;
    }

    try {
        $processed = processBatch();
    } catch (Throwable $exception) {
        error_log($exception->__toString());

        if ($runOnce) {
            exit(1);
        }

        sleep(5);
        continue;
    }

    if ($runOnce) {
        break;
    }

    if ($processed === 0) {
        sleep(2);
    }
}

$reason = $shutdownSignal === null ? 'completed' : "received signal {$shutdownSignal}";
fwrite(STDOUT, "Worker stopped: {$reason}.\n");

function processBatch(): int
{
    return 0;
}

function reloadConfiguration(): void
{
    fwrite(STDOUT, "Reloading configuration.\n");
}

The signal handlers only change flags. Database commits, file writes, and other non-trivial cleanup remain in the normal control flow, where interruption cannot leave half-updated application state. SIGTERM is the normal service-stop signal, SIGINT supports an interactive Ctrl+C, and SIGHUP requests a configuration reload.

pcntl_async_signals(true) replaces the old declare(ticks = 1) technique. A signal may interrupt sleep(), which is harmless here because the loop immediately checks $running again. The optional --once mode runs one batch for a deployment smoke test or a deliberately one-shot invocation.

Create a Dedicated Service Account

Do not run the worker as root and do not make the executable world-writable. On Debian or Ubuntu, create a system account and install the program with restrictive ownership:

sudo useradd --system \
  --home /var/lib/php-worker \
  --create-home \
  --shell /usr/sbin/nologin \
  php-worker

sudo install -d -o root -g php-worker -m 0750 /srv/php-worker
sudo install -m 0755 worker.php /srv/php-worker/worker.php

The worker should receive only the filesystem and database permissions its job requires. Keep deployment files under /srv/php-worker read-only to the service account when the application does not update itself.

Run the Worker with systemd

Create /etc/systemd/system/php-worker.service:

[Unit]
Description=Example PHP background worker
Wants=network-online.target
After=network-online.target

[Service]
Type=simple
User=php-worker
Group=php-worker
WorkingDirectory=/srv/php-worker
ExecStart=/usr/bin/php /srv/php-worker/worker.php
EnvironmentFile=-/etc/php-worker.env
Restart=on-failure
RestartSec=5s
TimeoutStopSec=30s
KillSignal=SIGTERM
StandardOutput=journal
StandardError=journal

NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
StateDirectory=php-worker

[Install]
WantedBy=multi-user.target

Type=simple is correct because the PHP process remains in the foreground. systemd knows its PID directly, so a separate PID file is unnecessary. Restart=on-failure restarts crashes and non-zero exits but does not fight a deliberate systemctl stop. StateDirectory creates a writable /var/lib/php-worker directory while ProtectSystem=strict keeps the rest of the filesystem read-only to the service.

Validate, load, and start the unit:

sudo systemd-analyze verify /etc/systemd/system/php-worker.service
sudo systemctl daemon-reload
sudo systemctl enable --now php-worker.service
sudo systemctl status php-worker.service

The leading minus on EnvironmentFile makes the file optional. If it contains credentials, create it as root with mode 0600. Environment variables can still appear in process inspection or crash diagnostics; use the platform's credential mechanism when that exposure is unacceptable.

Logs, Reloads, and Shutdown

Keep standard output and standard error open. The journal handles timestamps, rotation, retention, and concurrent writes without a custom PHP logger:

sudo journalctl -u php-worker.service -f
sudo systemctl kill --signal=HUP php-worker.service
sudo systemctl stop php-worker.service

The HUP command sets the reload flag. Stopping the unit sends SIGTERM and waits up to TimeoutStopSec before escalating. SIGKILL, commonly sent by kill -9, cannot be caught; no program can run cleanup after it. Jobs must therefore be recoverable even if the process disappears between two instructions.

Design the Work Loop for Failure

A worker should normally claim durable jobs transactionally rather than poll an arbitrary table and hope that two processes do not select the same row. For MySQL-backed workers, short locking transactions and explicit job states are more important than the daemon loop itself.

Use a Custom php.ini Only When Necessary

A service can select a dedicated configuration file explicitly:

ExecStart=/usr/bin/php -c /etc/php-worker/php.ini /srv/php-worker/worker.php

Prefer a small file containing only intentional overrides. Options such as magic_quotes_gpc, magic_quotes_runtime, and enable_dl belong to obsolete PHP versions and should not be copied into a current deployment.

When Self-Daemonization Is Unavoidable

Some environments provide no supervisor and still require the traditional Unix daemon protocol. This is a fallback, not the mode to use inside systemd, containers, Kubernetes, or another process manager. A correct detach requires more than one call to pcntl_fork():

  1. Open and exclusively lock the PID file.
  2. Fork and let the original parent exit.
  3. Call posix_setsid() to create a new session.
  4. Fork again so the final process cannot reacquire a controlling terminal.
  5. Set a predictable umask and working directory.
  6. Redirect standard streams to /dev/null or real log files.
  7. Write the final child PID while keeping the lock handle open.
function acquirePidFile(string $path)
{
    $handle = fopen($path, 'c+');

    if ($handle === false || !flock($handle, LOCK_EX | LOCK_NB)) {
        throw new RuntimeException('Another instance is already running.');
    }

    return $handle;
}

function daemonize(): array
{
    $firstPid = pcntl_fork();

    if ($firstPid === -1) {
        throw new RuntimeException('The first fork failed.');
    }

    if ($firstPid > 0) {
        exit(0);
    }

    if (posix_setsid() === -1) {
        throw new RuntimeException('Unable to create a new session.');
    }

    $secondPid = pcntl_fork();

    if ($secondPid === -1) {
        throw new RuntimeException('The second fork failed.');
    }

    if ($secondPid > 0) {
        exit(0);
    }

    umask(027);
    chdir('/');

    fclose(STDIN);
    fclose(STDOUT);
    fclose(STDERR);

    $stdin = fopen('/dev/null', 'r');
    $stdout = fopen('/dev/null', 'ab');
    $stderr = fopen('/dev/null', 'ab');

    if ($stdin === false || $stdout === false || $stderr === false) {
        throw new RuntimeException('Unable to redirect standard streams.');
    }

    return [$stdin, $stdout, $stderr];
}

$pidHandle = acquirePidFile('/run/php-worker.pid');
$standardStreams = daemonize();

ftruncate($pidHandle, 0);
rewind($pidHandle);
fwrite($pidHandle, (string) getmypid());
fflush($pidHandle);

Keep both $pidHandle and $standardStreams alive for the lifetime of the process. On shutdown, truncate and close the PID file before unlinking it. Open database and network connections only after the forks; otherwise parent and child inherit the same sockets. If privileges must be dropped in PHP, initialize supplementary groups and set the group ID before the user ID. A supervisor's User and Group directives are safer because privileged application code is never started.

The Historical PHP-Daemon Project

The original PHP-Daemon repository preserves the implementation that accompanied the earlier version of this article. It targets old PHP and Unix deployment practices and remains useful as historical context, but it should not be deployed unchanged. Current code should use cli_set_process_title() instead of the old PECL setproctitle() extension, asynchronous signals instead of ticks, and a supported autoloader instead of __autoload().

References