The core workflow: define a task, wrap it in a Thread, start it,
and optionally wait for the result.
Any class implementing Runnable can run in a background
process. All logic goes in run():
use Flytachi\Winter\Thread\Runnable;
class GenerateReport implements Runnable
{
public function __construct(private int $reportId) {}
public function run(array $args): void
{
// Executes in a separate, clean PHP process.
$format = $args['format'] ?? 'pdf';
// … heavy work …
}
}interface Runnable
{
public function run(array $args): void;
}run()is the only entry point; the whole task lives here.- Its return value is ignored — signal outcomes through the exit code
(
return/normal completion →0; throwing → non-zero) or through side effects (write to a DB, a file, a queue). $argsis an associative array of per-run values (see Arguments).
The task object is serialized in the parent and rebuilt in the child, so everything reachable from its properties must survive serialization:
- ✅ Store scalars, arrays, and plain serializable objects (IDs, DTOs, config).
- ✅ Closures and anonymous classes are fine —
opis/closurehandles them. - ❌ Do not store live resources: PDO/mysqli handles, open sockets, stream resources, cURL handles, or objects holding them. They cannot cross a process boundary.
Open those resources inside run() instead — the entire point is that the
worker starts with a clean slate and its own fresh connections:
public function run(array $args): void
{
$pdo = new PDO(...); // opened here, in the worker — not a property
// …
}Keep the task lean. The task's entire object graph is serialized and shipped over the transport on every
start(). A constructor holding a large array or a fat DTO means a large payload and a slower spawn. Pass an identifier and load the heavy data insiderun(), rather than embedding it in the task.
use Flytachi\Winter\Thread\Thread;
$thread = new Thread(
new GenerateReport(42),
'Reporting', // namespace (grouping, shown in the OS process title)
'ReportBuilder', // name (optional; auto-derived from the class if null)
'job-42' // tag (optional instance label)
);Constructing a Thread does not start anything. The three metadata fields
are cosmetic but invaluable in production — they form the OS process title
(visible in ps/htop):
WinterThread Reporting -> ReportBuilder@job-42
Details of each field:
namespace— a logical grouping (default'').name— if you passnull, it is auto-derived from the task's class: the short class name (e.g.GenerateReport), or the literalanonymousfor an anonymous class. Pass a string to override.tag— an optional instance discriminator (defaultnull); if omitted, the process title shows@runnable.
The process title is only set when
cli_set_process_title()is available on the platform; where it is not, the task still runs — you just don't get the prettypslabel.
start() serializes the task, launches the process, and returns its PID. It
does not block:
$pid = $thread->start();
echo "started $pid\n";
// main script keeps running immediatelypublic function start(
array $arguments = [],
bool $debugMode = false,
?string $outputTarget = '/dev/null',
bool $detached = false,
): int$arguments— per-run values (below).$debugMode— enable child-side error reporting (see 5. Output & Debugging).$outputTarget— where stdout/stderr go (see 5. Output & Debugging).$detached— daemonize for zombie-free fire-and-forget (see 9. Detached Mode).
Returns the launched process PID (int). Throws
ThreadException if the process
fails to start (e.g. proc_open denied, bad binary/runner path, or the process
dies immediately).
A Thread guards against being started while it is already running:
$thread->start();
$thread->start(); // ThreadException: "Thread is already running; join()/reap() it
// or create a new Thread before starting again."To run the same task again, either join()/reap() the previous run first, or —
more commonly — create a new Thread. Reusing a Thread after it has
finished and been reaped is allowed; reusing it while alive is not.
Pass per-run values as the first argument to start(). They arrive in run()'s
$args:
$thread->start(['format' => 'csv', 'compress' => true]);
public function run(array $args): void
{
$format = $args['format']; // 'csv' (string)
$gz = $args['compress']; // true (bool)
}Rules — worth knowing exactly, because they are strict:
- Values must be scalars or
null. Non-scalar, non-null values are silently dropped (arrays/objects don't cross as arguments — put structured data in the task's constructor instead, where it is serialized). truebecomes a valueless flag and comes back as booleantrue.falseandnullare skipped entirely — the key won't appear in$args. So test presence with??/isset, not=== false.- Every other scalar is stringified: an
int/float/bool-in-a-string arrives inrun()as a string ('42', not42). Cast as needed. - Keys are stringified too.
Internally they travel as escaped --arg-<key>[=<value>] CLI options and are
parsed back for you — you never touch the command line, and values cannot inject
into the shell.
Arguments vs. constructor. Use the constructor for the task's real payload (it is serialized, keeps types, and accepts any serializable value). Use
$argumentsfor small per-run scalar switches (format,verbose). If you find yourself flattening structures into arguments, move them to the constructor.
join() blocks until the task finishes and returns its exit code — 0 on
success, non-zero on failure:
$exit = $thread->join();
if ($exit !== 0) {
// the task threw or failed
}join(int $timeout = 0)— waits up to$timeoutseconds (0= forever). Returnsnullon timeout,-1if the thread was never started. Internally it polls process status every 50 ms.- If you never call
join()/reap(), see 6. Process Control for how the engine still avoids leaving zombie processes behind.
Don't want a result? Just start and move on. Output defaults to /dev/null, so
this is safe:
new Thread(new SendWelcomeEmail($userId))->start();For a long-lived parent (FPM worker, daemon) that never joins, use detached mode so no zombie accumulates:
new Thread(new SendWelcomeEmail($userId))->start(detached: true);| Outcome | Exit code | Where to look |
|---|---|---|
run() returns normally |
0 |
— |
run() throws an uncaught exception |
non-zero (1) |
message + stack trace on STDERR |
Payload empty / not a Runnable |
1 |
STDERR |
| Payload can't be deserialized (tampered, or signed with the wrong secret) | 1 |
STDERR; no task code runs |
The runner always catches exceptions from run() — it logs the message and
trace to STDERR and exits non-zero — so failures are detectable via join() even
without debug mode. Where STDERR goes depends on
your $outputTarget.
Avoid
exit()/die()insiderun(). They bypass the runner's normal path: the exit code becomes whatever you passed toexit()(soexit(0)on a failure would look like success), and the exception handling above is skipped. Return normally for success, andthrowfor failure — let the runner set the code.