Winter Thread is a process engine for PHP: a clean, object-oriented, Java-like API for running and controlling background tasks as isolated OS processes — for parallel and long-running work.
No heavy extensions. Unlike pthreads, ext-parallel, or Swoole, it needs
no ZTS build and no exotic runtime — just proc_open and the standard POSIX
extensions (ext-pcntl, ext-posix) that ship with nearly every PHP install.
Each task runs in a fresh, isolated PHP process, so there is no shared state to
corrupt and no inherited connections to break.
It is deliberately a low-level engine — a small, dependable core to build
pools, queues and schedulers on — that abstracts away proc_open and POSIX
signals behind a friendly API.
- No heavy extensions: No swoole / parallel / pthreads, no ZTS build — just
proc_open+ standard POSIX. Runs on a normal PHP install. - Clean process isolation: Each task runs in a brand-new PHP process — no inherited DB connections, sockets, or global state to corrupt.
- Fluent, Object-Oriented API: Manage background processes as objects.
- Full Process Control:
start(),join(),pause(),resume(),terminate(), andkill(). - Advanced Process Naming: Identify your processes easily with namespaces, names, and tags.
- Safe by Default: Output goes to
/dev/nullby default — no Broken pipe risk for fire-and-forget jobs. - Swoole / Event-Loop Compatible: Pluggable payload transports (pipe, temp-file, shared-memory); the default engine auto-detects an active Swoole runtime and avoids fd corruption under
SWOOLE_HOOK_ALL. - Zombie-free fire-and-forget: Optional detached mode (
fork+setsid) reparents workers to init, so long-lived parents (FPM, daemons) never accumulate zombies. - Pluggable Engine: Swap the payload transport or the launcher through a single
Engine— build custom backends (Docker, SSH, …) without touchingThread. - Java-like API: Familiar method names like
isAlive()andjoin()for an easy learning curve.
- PHP >= 8.4
ext-pcntlext-posixopis/closure^4.5 (required; enables safe serialization of anonymous classes and closures)ext-shmop(optional; only for the shared-memory transport)
composer require flytachi/winter-thread<?php
require 'vendor/autoload.php';
use Flytachi\Winter\Thread\Runnable;
use Flytachi\Winter\Thread\Thread;
// 1. Define your task by implementing Runnable.
// Logic inside run() executes in a separate process.
class VideoProcessingTask implements Runnable {
public function __construct(private string $videoFile) {}
public function run(array $args): void {
$quality = $args['quality'] ?? 'high';
// output goes to /dev/null by default — use outputTarget for logging
sleep(5); // simulate encoding
}
}
// 2. Create a Thread with optional metadata for OS process identification.
$thread = new Thread(
new VideoProcessingTask('movie.mp4'),
'Media', // namespace
'VideoProcessor', // name
'job-42' // tag
);
// 3. Start the thread.
// Default outputTarget='/dev/null' — safe for fire-and-forget.
// Pass outputTarget: '/path/to/file.log' to capture output.
// Pass outputTarget: null ONLY when actively reading via readOutput().
$pid = $thread->start(['quality' => 'hd']);
echo "Processing started (PID: $pid)\n";
// Main script continues immediately.
echo "Doing other work...\n";
// 4. Optionally wait for the task to finish.
$exitCode = $thread->join();
echo "Task finished with exit code: $exitCode\n";All configuration goes through a single Engine, bound once at bootstrap with
Thread::bindEngine(). When you bind nothing, the AdaptiveEngine is used and
self-configures for the current environment (CLI / FPM / Swoole).
use Flytachi\Winter\Thread\Engine\ManualEngine;
use Flytachi\Winter\Thread\Payload\TempFileTransport;
// Zero-config: AdaptiveEngine is the default — nothing to do.
$thread = new Thread(new MyTask());
$thread->start();
// Explicit configuration when you need it:
Thread::bindEngine(
(new ManualEngine())
->withTransport(new TempFileTransport())
->withBinaryPath('/usr/bin/php')
->withRunnerPath(__DIR__ . '/vendor/flytachi/winter-thread/wRunner')
->withSecurity('your-signing-secret') // signs serialized closures
->withLauncher(new MyCustomLauncher()) // optional: custom backend
);Under Swoole with SWOOLE_HOOK_ALL, stdin pipes created by proc_open are intercepted
and their file descriptors leak into Swoole's internal table, causing Bad file descriptor
errors. The AdaptiveEngine detects an active Swoole runtime automatically and switches
to a file/shared-memory transport, so no configuration is needed. Under Swoole, also prefer
file output over outputTarget: null (the output pipes are subject to the same hooks).
| Transport | Delivery | Parent pipe fd | Requires |
|---|---|---|---|
PipeTransport |
stdin pipe (default in CLI) | yes | — |
TempFileTransport |
temp file as stdin | none | — |
ShmTransport |
shared memory | none | ext-shmop |
For a long-lived parent (FPM worker, daemon) that dispatches background tasks and never
joins them, pass detached: true. The launcher exits immediately and the real worker is
reparented to init (pid 1), so no zombie ever accumulates under the parent:
$thread = new Thread(new SendEmailBatch($ids));
$thread->start(detached: true); // returns at once; worker owned by initSignal control still works via the worker's self-reported PID (write getmypid() from
inside the task to your own store), since the engine's control model is PID-based.
$outputTarget |
Use case |
|---|---|
'/dev/null' (default) |
Fire-and-forget: safe, output discarded |
'/path/to/file.log' |
Persistent logging for staging/production |
null (explicit) |
Interactive: parent polls readOutput() / readError() |
Important: Never pass
nullunless the parent actively drains the pipe in a polling loop. A full buffer causes a Broken pipe that silently kills the background job.
$thread->pause(); // SIGSTOP — suspend execution
$thread->resume(); // SIGCONT — resume after pause
$thread->terminate(); // SIGTERM — graceful shutdown request
$thread->kill(); // SIGKILL — force kill (last resort)
$thread->interrupt(); // SIGINT — Ctrl+C equivalent
$thread->isAlive(); // bool — check if still runningTests come in two tiers (mirroring the winter-kernel layout):
Default — runs on any machine; unsupported extensions self-skip:
composer install
composer test # base (class correctness) + working (scenarios)
composer test-base # only unit-level class correctness
composer test-working # only end-to-end scenarios
composer test-detail # human-readable (testdox) outputContainered — heavy, environment-specific checks (leak / timing / nested / battle-run, with Cli / FPM / Swoole), run inside Docker across a list of PHP versions:
tests/run-container.sh # default versions: 8.4 8.5
tests/run-container.sh 8.4 # a single version
tests/run-container.sh 8.4 8.5 8.6 # a custom list
# Or, inside an environment that already has swoole/shmop:
composer test-container # phpunit --testsuite containerCI (.github/workflows/ci.yml) runs the default suite via setup-php and the container
suite via the bundled tests/docker/Dockerfile, on a PHP 8.4 / 8.5 matrix.
Full documentation lives in /docs:
- Introduction — philosophy, the no-heavy-ext story, when to use it
- Installation & Requirements
- Quickstart — a complete parallel example in 5 minutes
- Basic Usage
- Output & Debugging
- Process Control & Lifecycle — signals, graceful shutdown
- The Engine
- Payload Transports
- Detached Mode
- Security
- Architecture & Internals
- Patterns — pools, returning results, retries
- Troubleshooting
- API Reference
- Testing
Contributions are welcome! Please submit a pull request or open an issue for bugs, questions, or feature requests.
This library is open-source software licensed under the MIT license.