Everything configurable lives behind a single abstraction: the
Engine. It decides how a task is delivered and
launched — the payload transport, the launcher, the PHP binary and wRunner
paths, and the optional signing secret. (Running the task in the worker is a
separate child-side concern; see Parent-side only.)
You bind one once at bootstrap:
Thread::bindEngine($engine);If you bind nothing, the default AdaptiveEngine
is used — so the zero-config case just works. The binding is a process-wide
static shared by every Thread in the process.
interface Engine
{
public function transport(): PayloadTransport; // how the payload is delivered
public function launcher(): Launcher; // how the process is spawned
public function binaryPath(): string; // PHP CLI binary
public function runnerPath(): string; // wRunner bootstrap script
public function security(): ?DefaultSecurityProvider; // payload signing (or null)
}Two implementations ship with the library: AdaptiveEngine (self-configuring,
the default) and ManualEngine (explicit, clean slate).
The Engine lives entirely in your process (the parent). It is used to
serialize() the task, choose the transport(), build the launcher(), resolve
binaryPath()/runnerPath(), and sign the payload via security(). It is not
shipped to the child and has no child-side method on its interface: running the
task in the worker is the job of a separate
AdaptiveRunner, which the wRunner bootstrap constructs on
its own. Engine (parent) and Runner (child) are independent — they don't reference
each other; the only thing that crosses the boundary is the payload and a couple of
CLI flags/env vars.
Two consequences follow directly, and they explain the whole configuration model:
- The secret reaches the child through the environment, not through your bound
object. When the parent's engine has a secret, the built-in
CliLauncherinjects it into the child's environment asWINTER_THREAD_SECRET. The child (wRunner) reads that env var directly to build its verifier — so signing works even if the parent used aManualEnginewith an explicit secret; the value is propagated for you. (See 10. Security for why env and not argv.) - The child picks its receiving transport from CLI options, not from your bound
transport. If a
--shmkeyoption is present the child reads shared memory; otherwise it reads STDIN (which serves both the pipe and temp-file transports identically). This is why parent and child stay consistent without shipping the whole engine across the boundary.
Implication for custom launchers. If you replace the
Launcher(SSH, Docker, remote node), you become responsible for running the correctwRunneron the far side, delivering the payload to its STDIN (or shm), and — if you sign — forwardingWINTER_THREAD_SECRETinto the remote environment. The defaultCliLauncherdoes all of this locally.
Detects the environment at construction and picks sensible defaults; every part is overridable through the constructor.
new AdaptiveEngine(
secret: null, // else WINTER_THREAD_SECRET env, else no signing
transport: null, // else auto: TempFile under an active Swoole runtime, else Pipe
binaryPath: null, // else the resolved real PHP CLI binary
runnerPath: null, // else the packaged wRunner
launcher: null, // else the default CliLauncher (built from the above)
);What it resolves, exactly:
- Secret — the explicit
secretargument, else theWINTER_THREAD_SECRETenvironment variable, elsenull(no signing). - Transport —
TempFileTransportonly when a Swoole runtime is active — detected as being inside a coroutine (\Swoole\Coroutine::getCid() !== -1) or with runtime hooks enabled (\Swoole\Runtime::getHookFlags() !== 0) — otherwisePipeTransport. If theswooleextension isn't loaded at all, it's always Pipe. - Binary path — under a CLI SAPI (
cli/cli-server) it usesPHP_BINARY(the running interpreter); under a non-CLI SAPI (FPM/CGI) it resolvesPHP_BINDIR . '/php'if that is executable, else falls back to'php'onPATH. This is what makes it work correctly under FPM. - Runner path — the
wRunnerscript in the installed package root. - Launcher — if you pass a
launcher, it is returned as-is; otherwise aCliLauncheris built from the resolved binary/runner/transport plus the child-env (which carries the secret).
Because it's the default, most applications never touch the engine at all:
$thread = new Thread(new MyTask());
$thread->start(); // AdaptiveEngine, fully configuredAdaptiveEngine is a final readonly class — immutable once constructed. To
change one aspect, construct a new one with the relevant named argument.
Detects nothing. You set each part with immutable withers (each returns a
clone — the original is untouched). A required part left unset throws
ThreadException when accessed. Predictable, no environment magic.
Thread::bindEngine(
(new ManualEngine())
->withTransport(new TempFileTransport())
->withBinaryPath('/usr/bin/php')
->withRunnerPath(__DIR__ . '/vendor/flytachi/winter-thread/wRunner')
->withSecurity('your-signing-secret')
->withLauncher(new MyCustomLauncher()) // optional
);Which parts are required depends on how the launcher is resolved:
- Default launcher path —
transport,binaryPathandrunnerPathmust all be set (each getter throws"ManualEngine: <part> is not configured."if not).secretis optional (no signing when absent). - Custom launcher path — if you set
withLauncher(...), that launcher is returned as-is and the engine does not requirebinaryPath/runnerPath/transport— your launcher owns its own wiring.
Use ManualEngine when you want an explicit, environment-independent config
(reproducible across CLI/FPM/containers) or a custom backend.
The Launcher is the seam for new backends (Docker, SSH, remote nodes). Provide
a built instance and the engine returns it unchanged; wiring transport/secret/
wRunner into a custom backend is your responsibility (see
the distinction above).
new AdaptiveEngine(launcher: new MySshLauncher(/* host, key, … */));
(new ManualEngine())->withLauncher(new MyDockerLauncher(/* image, … */));Interfaces are the only extension points that need implementing (Engine,
Launcher, Runner, PayloadTransport); ProcessHandle and LaunchSpec are
concrete value/handle types you consume, not implement. See
11. Architecture.
Framework code that builds its own pool can reach the launcher and handle without
the Thread facade:
$engine = Thread::engine(); // the bound engine (lazily an AdaptiveEngine)
$launcher = $engine->launcher(); // the parent-side spawn strategy
$handle = $launcher->launch($spec); // a ProcessHandle for a LaunchSpecThread::engine() returns the currently bound engine, lazily creating a default
AdaptiveEngine the first time if none was bound.
bindEngine() sets a process-wide static. To go back to defaults, bind a fresh
AdaptiveEngine:
Thread::bindEngine(new AdaptiveEngine());There is no separate "unbind" — rebinding replaces the previous engine for all
subsequent Thread operations in the process.