Installation & Requirements
Winter Thread targets a POSIX PHP CLI environment. Install it with one Composer command, confirm a couple of extensions are present, and — for most apps — write no configuration at all.
Install
composer require flytachi/winter-threadInstalling the package also exposes the child bootstrap script as vendor/bin/wRunner. You
never call it by hand — the engine invokes it for you — but it must remain executable and
reachable on disk (see The runner path below).
Requirements
| Requirement | Why | Mandatory? |
|---|---|---|
| PHP >= 8.4 | modern language features (readonly classes, first-class callable syntax, named args) |
Required |
ext-pcntl |
pcntl_fork() for detached mode |
Required (Composer) |
ext-posix |
posix_kill() (signals) and posix_setsid() (detached mode) |
Required (Composer) |
opis/closure ^4.5 |
safe serialization of closures / anonymous classes and signed payloads | Required (Composer) |
ext-shmop |
only for the shared-memory transport (ShmTransport) |
Optional |
ext-pcntl and ext-posix are standard, lightweight POSIX extensions bundled with almost
every Linux/macOS PHP CLI — nothing exotic. There is no ZTS build requirement and no
heavy extension (swoole / parallel / pthreads) involved.
opis/closure is a hard dependency and is installed automatically by
composer require flytachi/winter-thread — there is no separate step. It is what lets anonymous
classes and Closure objects be serialized and executed in a background process, and it signs
payloads when a secret is set; PHP’s native serialize() cannot handle closures or
class@anonymous and throws on them.
Check installed extensions with php -m:
php -m | grep -E 'pcntl|posix|shmop'What each dependency gates
The bare spawn/wait path (start() → join()/reap()) only needs proc_open. ext-posix
powers signal control (pause, resume, interrupt, terminate, kill); ext-pcntl powers
detached mode’s fork. The package requires both so the full feature set always works — if a
platform lacks one, only the corresponding feature is affected.
ext-shmop (optional)
The shared-memory transport (ShmTransport, useful under Swoole) is the only feature that needs
ext-shmop. It is checked at runtime: staging or receiving a payload throws a clear
ThreadException ("ShmTransport requires ext-shmop.") if the extension is missing — never a
fatal error. If it is unavailable, use TempFileTransport instead, which needs no extra
extension. See Payload delivery.
Operating system
Winter Thread targets a POSIX-compliant OS (Linux, macOS, BSD). It relies on POSIX signals,
setsid, and /proc, and is developed and tested on Linux and macOS. Windows is not
supported.
Bootstrap configuration
Configuration is process-wide and lives in the Launcher — the parent-side object that spawns the process and owns the payload-signing secret. A concrete launcher also knows the PHP binary, the runner-script path and the payload transport, but those are its own business. Bind a launcher once during your application’s bootstrap.
Zero-config (recommended)
For most applications there is nothing to configure. If you bind nothing, the first Thread
lazily creates an AdaptiveLauncher, which picks a backend per launch to match the runtime:
SwooleLauncher inside a Swoole coroutine, CliLauncher on plain CLI and under FPM. Just start
threads:
<?php
require 'vendor/autoload.php';
use Flytachi\Winter\Thread\Runnable;
use Flytachi\Winter\Thread\Thread;
$thread = new Thread(new class implements Runnable {
public function run(array $args): void { /* work here */ }
});
$thread->start();Overriding individual parts
To set something of your own and leave the rest auto-detected, build CliLauncher::adaptive()
with named arguments and bind it through Thread::bindLauncher():
<?php
use Flytachi\Winter\Thread\Thread;
use Flytachi\Winter\Thread\Launch\CliLauncher;
use Flytachi\Winter\Thread\Payload\TempFileTransport;
Thread::bindLauncher(CliLauncher::adaptive(
secret: 'your-secret-key',
transport: new TempFileTransport(),
));Whatever you leave out resolves itself: binaryPath from the current SAPI, runnerPath from the
installed package, secret from WINTER_THREAD_SECRET.
Setting everything explicitly
A fully reproducible build that touches no environment — what you want inside a container:
<?php
use Flytachi\Winter\Thread\Thread;
use Flytachi\Winter\Thread\Launch\CliLauncher;
use Flytachi\Winter\Thread\Payload\TempFileTransport;
Thread::bindLauncher(new CliLauncher(
binaryPath: '/usr/bin/php',
runnerPath: __DIR__ . '/vendor/flytachi/winter-thread/wRunner',
transport: new TempFileTransport(), // null — detect on every launch
secret: 'your-signing-secret', // null — no signing
));The transport is detected at launch, not at bind time
Left null, the transport is chosen inside each launch(): TempFileTransport when a Swoole
runtime is active, PipeTransport otherwise. This is deliberate — a launcher bound during
preload, before any worker or coroutine exists, would otherwise freeze the wrong choice.
CliLauncher is final readonly: to change one part, construct a new one. The full surface is in
the API reference.
A bare CliLauncher is for outside a coroutine only
CliLauncher uses proc_open, which corrupts the reactor’s descriptors when called from inside
a live Swoole coroutine — the second spawn fails with Bad file descriptor. You do not normally
hit this: the default AdaptiveLauncher routes to SwooleLauncher there. Bind a bare
CliLauncher only for CLI and FPM.
The secret
A secret enables HMAC signing of the serialized payload (via opis/closure), so a forged or
tampered payload is rejected before any object is built in the child process. Signing is
opt-in but strongly recommended whenever you serialize closures or anonymous classes.
You can supply it two ways — the launcher’s secret: argument (shown above) or the
WINTER_THREAD_SECRET environment variable, which the launcher reads itself when the
argument is omitted. That is the precedence: explicit argument, then the env var, otherwise no
signing.
export WINTER_THREAD_SECRET='your-secret-key'The secret reaches the child through that env var (owner-only /proc/<pid>/environ), never
through argv.
Set a secret in production
Without a secret, payloads are still handled by opis/closure (never native unserialize()),
but they are unverified — trust falls back to the private, owner-only delivery channel. Set a
secret to reject forged payloads before they can execute code in a child process.
Environment notes
PHP-FPM / web SAPI
proc_open must be permitted (not listed in disable_functions). Under FPM/CGI, PHP_BINARY
points at the FPM binary, not a CLI one — running your worker through that would be wrong.
CliLauncher accounts for this: under a CLI SAPI it takes PHP_BINARY, and under a non-CLI one
it resolves the real PHP CLI binary from PHP_BINDIR/php when that is executable, falling back to
php on PATH. If detection fails in an unusual setup, set the path explicitly with the
binaryPath: argument.
The runner path
The child is bootstrapped by the wRunner script shipped in the package root. The launcher
locates it automatically at vendor/flytachi/winter-thread/wRunner. Two situations need
attention:
- Phar / relocated deployments. If your code is packed into a
.phar, or the vendor directory is not on a normal filesystem path, the script may not be directly executable. Point at a real on-disk copy with therunnerPath:argument. open_basedir. The binary and runner paths must be inside any configuredopen_basedir.
Containers
If you run detached tasks with your app as PID 1 in a container, add a reaping init
(docker run --init, or init: true in Compose) so orphaned workers are collected. Without it,
detached workers reparent to your app (PID 1), which does not reap them, and they accumulate as
zombies. Attached tasks that you join()/reap() do not need this.
Verify your install
<?php
require 'vendor/autoload.php';
use Flytachi\Winter\Thread\Runnable;
use Flytachi\Winter\Thread\Thread;
$thread = new Thread(new class implements Runnable {
public function run(array $args): void { /* nothing */ }
});
echo 'PID: ' . $thread->start() . PHP_EOL;
echo 'exit: ' . $thread->join() . PHP_EOL; // 0Expected output is a numeric PID followed by exit: 0. Anonymous classes work because
opis/closure is a hard dependency — you are not restricted to named task classes (though named
classes give more readable process titles and simpler debugging).
A named task class must be autoloadable
An anonymous class travels to the child whole; a named one travels only as a name, and the
child loads it itself through vendor/autoload.php. A class declared inside the script — or
anywhere outside autoloading — is not found there, and the task fails with exit: 1. The reason
becomes visible once output goes to a file instead of /dev/null:
Error: failed to deserialize payload: Class "OnlyHereTask" does not existSo keep task classes where the rest of the application lives — under PSR-4 autoloading.
Next, walk through a real task in the Quickstart.