Processes
A process is a long-lived worker: it starts once and runs until it is stopped. It listens to a queue, polls equipment, holds a connection to an external system. Unlike a request, it has no end — it has a loop.
What a process is and why
A process is an application component that runs continuously, outside the request-response cycle.
The problem. Some work does not fit the request model. A queue has to be listened to constantly, not when somebody opens a page. Polling equipment over SNMP must proceed on its own. A daemon reading from a socket has to keep the connection open for weeks.
The usual answer is a separate PHP script under nohup or a systemd unit. And
immediately everything nobody thinks about while writing that script surfaces:
- on
SIGTERMthe script dies in the middle of handling a task, losing it; - “stop it” means finding a PID, and “is it alive” means grepping
ps; - started twice, both run, and nobody finds out;
- the script has its own bootstrap, its own configuration and its own logs, separate from the application.
The solution. Winter provides a stereotype: you describe what to do in the loop and the framework takes the rest — signals and graceful stop, protection against a second start, registration in the system, status reporting, and a container shared with the application.
Quick start
Extend Process and describe the body in run():
<?php
namespace Main\Process;
use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Process\Stereotype\Process;
class InboxWatcher extends Process
{
#[Autowired] private DocumentImporter $importer;
public function run(): void
{
$this->logger->info('watching /var/inbox');
while ($this->isRunning()) {
$files = glob('/var/inbox/*.xml');
if ($files === []) {
$this->sleep(2); // nothing there — wait
continue;
}
foreach ($files as $file) {
$this->markBusy();
$this->importer->import($file);
unlink($file);
$this->markIdle();
}
}
}
}Declare it in the application manifest and it comes up with everything else:
#[EnableWeb]
#[EnableProcess(\Main\Process\InboxWatcher::class)]
final class Application extends WinterApplication { /* ... */ }Or start it on its own, without the application:
php call process main.process.InboxWatcher start -d # in the background
php call process main.process.InboxWatcher status # how is it doing
php call process main.process.InboxWatcher stop # stop it gracefullyThe container builds the instance
A process is never created with new — the constructor is final and closed. The
object is built by the container, so #[Autowired] and all the usual dependencies
work inside it. From outside, a process is controlled with static methods:
start(), dispatch(), status(), stop().
The process body
The loop and isRunning()
run() is the only method you must implement. Inside it almost always holds a loop,
and the loop condition must be isRunning():
while ($this->isRunning()) {
// ...
}The method returns false as soon as a stop signal arrives. That makes stopping
cooperative: the process is not killed mid-sentence, it reaches the end of the
turn and exits. while (true) takes that away from you.
The pause — sleep()
PHP’s ordinary sleep() blocks the whole process: a stop signal waits for the pause
to end, and under Swoole every parallel task stops with it. Use the stereotype’s
method:
$this->sleep(1.5); // seconds, fractions allowedIt does not block the runtime and is interrupted by a stop: if the signal
arrives during the pause, an InterruptedException is thrown instead of finishing
the sleep.
try {
$this->sleep(30);
} catch (InterruptedException) {
// stopped during the pause — return the task to the queue and exit
}Catching it is optional: uncaught, the process simply winds down gracefully, and
finally blocks still run.
Units of work — markBusy() and markIdle()
Between these calls the process counts as busy. That gives you two things:
- A busy process is not interrupted mid-work — the stop waits for
markIdle(). - Busyness is visible in
call process statusand is taken into account by a daemon’s supervisor.
$this->markBusy();
try {
$this->handle($job);
} finally {
$this->markIdle();
}`markBusy()` also resets the request scope
It is not only a busy marker but the container’s “one task” boundary: at
markBusy() every #[Request] object is created afresh.
Without it a #[Request] dependency obtained on the first turn lives until the
process ends and drags the previous task’s state into the next. In a long-lived
worker that is one of the nastiest bugs: the previous job’s data quietly leaks into
the current one. Mark units of work even where busyness itself does not matter.
The liveness signal — touch()
While a process yields — waiting on a file, the database, the network, or sleeping — the framework marks it alive for you. But when work goes into a long computation, or a call that never yields, from outside that is indistinguishable from a hang.
Inside such work, signal liveness yourself:
foreach ($rows as $i => $row) {
$this->recalculate($row); // computing, waiting on nothing
if ($i % 1000 === 0) {
$this->touch(); // "I am alive, just busy"
}
}The call is cheap and guarded against frequent repeats, so it is safe inside a loop.
Only needed under supervision
For a lone process touch() does nothing — nobody is watching it. The method exists
for daemons: there a watchdog kills a worker silent for
longer than $livenessTimeout, and without an explicit liveness signal it would
kill a healthy but busy one.
What else is available inside
The stereotype hands the process body a small toolkit. Everything listed is
available through $this with no declarations.
| What | Type | What for |
|---|---|---|
isRunning() |
bool |
The loop condition: false after a stop signal |
sleep(float $seconds) |
void |
An interruptible pause |
markBusy() / markIdle() |
void |
The boundaries of a unit of work |
activity() |
Activity |
Whether the process is busy right now |
spawn(callable $task) |
Future |
Run a task in parallel |
touch() |
void |
A liveness signal during long work |
requestStop() |
void |
Stop yourself from inside |
$this->logger |
LoggerInterface |
A logger already named after the process class |
$this->pid |
int |
Your own process id |
Two of them deserve a note.
$this->logger needs no declaring. It is already there and already named after
the process class, so #[Autowired] private LoggerInterface $logger in a process is
a wasted line:
$this->logger->info('batch processed', ['count' => count($batch)]);activity() answers “am I busy right now?” — counting both explicit
markBusy() calls and unfinished spawn() tasks. Useful when the decision to take
a new portion depends on whether the previous one was cleared:
use Flytachi\Winter\Kernel\Process\Activity;
while ($this->isRunning()) {
if ($this->activity() === Activity::BUSY) {
$this->sleep(0.2); // the previous batch is still in progress
continue;
}
foreach ($this->jobs->takeBatch(50) as $job) {
$this->spawn(fn() => $this->handle($job));
}
}Without that check the loop would keep taking on tasks until it hit $concurrency,
building a backlog of waiting coroutines.
requestStop() stops the process by its own decision — the same effect as an
external signal. It fits when the work is substantively over:
if ($this->migration->isComplete()) {
$this->logger->info('migration finished, stopping');
$this->requestStop();
}Parallel work — spawn()
When tasks are independent they can be handled at the same time:
while ($this->isRunning()) {
foreach ($this->jobs->takeBatch(10) as $job) {
$this->spawn(fn() => $this->handle($job));
}
$this->sleep(1);
}Under Swoole each task runs in its own coroutine; while one waits on the database or the network, the others work. The process counts as busy while unfinished tasks remain and will not exit without waiting for them.
The number of concurrent tasks is capped by a class property:
protected int $concurrency = 20; // 0 (the default) — no limitThe task result
spawn() returns a Future — the same promise object as
#[Async]. If you do not need the result, the return value is
simply discarded, as in the example above. If you do, collect the promises and take
the values:
$futures = [];
foreach ($this->sources->all() as $source) {
$futures[$source->id] = $this->spawn(fn() => $this->fetch($source));
}
foreach ($futures as $id => $future) {
$this->results->save($id, $future->get()); // waits if not ready yet
}Here every call goes out at once and get() waits for each in turn — the total time
equals the longest of them, not their sum.
Future method |
What it does |
|---|---|
get(?float $timeout = null) |
Waits and returns the result; the task’s exception is thrown here |
isDone() |
Whether it is finished, without blocking |
cancel() |
Cancel it, if it has not started running |
An exception inside a task does not bring down the process — it is stored in the
Future and surfaces at get(). A task whose result nobody collects shows its
error to no one, so in such places catch it yourself and write it to the log.
It speeds up waiting, not computing
Tasks run as coroutines: they do not compute simultaneously, they interleave when one hits a wait — the network, the database, a file, a pause. Ten HTTP requests go out at once; ten heavy computations queue up and take exactly as long as they would one after another.
Settings
Three protected properties govern a process’s behaviour.
| Property | Default | What it sets |
|---|---|---|
$concurrency |
0 |
The cap on concurrent spawn() tasks; 0 — no cap |
$grace |
0.0 |
How many seconds to wait for work to finish on stop; 0 — wait indefinitely |
$processTitle |
null |
The name in ps and in status output; the class short name by default |
class InboxWatcher extends Process
{
protected int $concurrency = 20;
protected float $grace = 30.0;
protected ?string $processTitle = 'inbox';
}$grace is worth setting deliberately: at its default value a stop waits for work
to finish indefinitely. That is right when a task must not be abandoned, and
dangerous during a deployment — one stuck worker holds up the rollout. Tens of
seconds is usually a sensible compromise.
The process name in ps
A process names itself, taking the class short name by default. If another name is more convenient in the system, set the property:
protected ?string $processTitle = 'inbox-watcher';winter-proc: inbox-watcherWhen a property is not enough — say the name should include a startup parameter —
override titleName(). It returns the name without the runtime prefix; the prefix
is added on top.
Reacting to signals
Every hook is optional — override only the ones you need.
| Hook | When it is called | Stops the process |
|---|---|---|
onTerminate() |
SIGTERM — a normal stop |
yes, guaranteed |
onInterrupt() |
SIGINT — Ctrl-C |
yes, guaranteed |
onReload() |
SIGHUP — re-read configuration |
no |
onUser1() |
SIGUSR1 — yours to define |
no |
onUser2() |
SIGUSR2 — yours to define |
no |
onShutdown() |
Any exit, including a crash | — |
protected function onTerminate(): void
{
$this->logger->info('stopping, will finish the current job');
}
protected function onReload(): void
{
$this->settings->reload(); // SIGHUP — re-read and carry on
}
protected function onShutdown(): void
{
$this->connection->close(); // runs whatever happens
}onShutdown() is the place to release resources: it runs on every exit path,
a fatal error included. Stopping itself is guaranteed without onTerminate() — that
hook is only for when you want to react to it.
You can stop yourself from inside with requestStop() — the same effect as
SIGTERM, but by your own decision.
A second signal does not hurry the stop
The first SIGTERM begins a graceful stop; subsequent ones are ignored. Only an
expired $grace or an external SIGKILL terminates forcibly.
Three ways to start
A process is an ordinary class and can be raised three ways. The difference is not in the process’s behaviour but in who starts it and who watches it.
Manifest #[EnableProcess] |
Console call process |
From code | |
|---|---|---|---|
| Who starts it | php call run, with the application |
You or the init system | Your code: a controller, a command, another process |
| Who watches it | The application master: it crashed, it comes back | Nobody | Nobody |
| When it stops | With the application | By a separate command | By calling stop() |
| When it fits | The process is a permanent part of the service | A separate deployment, a one-off run | Starting on an event: from an admin panel, a button, after an upload |
Not both at once
A process is a singleton: declared in the manifest and started by hand, the
second instance will not come up. From code or the console that is a
ProcessAlreadyRunningException; at application startup it is a line in the log.
Control from code
The same operations the console performs are available as static methods on the process class. That is the third way: start processing at the press of a button in an admin panel, stop a stuck import, show the state on a page.
| Method | What it does |
|---|---|
start(): void |
Runs it in the current process and does not return until it finishes |
dispatch(?string $output = '/dev/null'): int |
Runs it in the background and returns the PID at once |
status(bool $usage = false): ?ProcessStatus |
The state, or null when it is not running |
stop(): bool |
Sends a graceful stop signal; false when there is nothing to stop |
`start()` is not for the web
start() blocks its caller until the process finishes — and a process, as a rule,
never finishes. Called from a controller it hangs the request forever.
From the web, and generally from any code that needs to carry on, use dispatch().
Starting and stopping
use Main\Process\InboxWatcher;
$pid = InboxWatcher::dispatch(); // went into the background, returned a PID
InboxWatcher::stop(); // graceful stop: true if the signal was sentThe dispatch() argument decides where the child’s output goes. By default it is
discarded; pass a path to keep it:
InboxWatcher::dispatch('/var/log/inbox-watcher.out');A repeated dispatch() while the process is running throws
ProcessAlreadyRunningException — that is exactly how a second instance is
prevented.
Reading the state
status() returns null when the process is not running — the simplest “is it
alive” check:
if (InboxWatcher::status() === null) {
InboxWatcher::dispatch();
}When you need the details, the status object exposes:
| Field | Type | What it holds |
|---|---|---|
$pid |
int |
The process id |
$className |
string |
The process class |
$state |
ProcessState |
The process state — see the table below |
$activity |
Activity |
IDLE or BUSY — whether it is working right now |
$startedAt |
int |
The start time, as unix time |
$concurrency |
int |
The concurrent-task cap |
$heartbeatAt |
int |
When it last signalled liveness |
$usage |
?ResourceUsage |
Resource usage — only when asked for |
Plus a getStartedAt(): string method — the same time in readable form.
The states $state can return:
| Value | What it means |
|---|---|
NEW |
The record exists, work has not begun |
RUNNING |
Working |
STOPPING |
Received a stop signal and is finishing the current work |
TERMINATED |
Exited cleanly |
FAILED |
Exited with an error |
RESTARTING |
Coming back up — the state of a worker under a daemon’s supervision |
In an ordinary process’s life you meet the first three: status() returns null as
soon as the process is gone, so TERMINATED and FAILED appear not on it but on
fleet workers, where the record outlives the process itself.
Resource usage is not collected unless asked for, because it costs a call to a system utility. Request it deliberately:
$status = InboxWatcher::status(usage: true);
$status->usage?->cpu; // CPU percentage
$status->usage?->rssKb; // resident memory, KB
$status->usage?->elapsed; // how long it has been runningThe status object serialises itself
ProcessStatus implements JsonSerializable, so it can be returned from a
controller as it is — the result is ready JSON with pid, state, activity,
uptime and the rest.
Example: control from an admin panel
<?php
namespace Main\Admin;
use Flytachi\Winter\Kernel\Http\Response\ResponseEntity;
use Flytachi\Winter\Kernel\Http\Stereotype\Controller;
use Flytachi\Winter\Kernel\Process\ProcessAlreadyRunningException;
use Flytachi\Winter\Kernel\Route\Annotation\{GetMapping, PostMapping, RequestMapping};
use Main\Process\InboxWatcher;
#[RequestMapping('admin/inbox-watcher')]
class ProcessController extends Controller
{
#[GetMapping]
public function show(): ResponseEntity
{
$status = InboxWatcher::status(usage: true);
return $status === null
? ResponseEntity::ok(['running' => false])
: ResponseEntity::ok($status);
}
#[PostMapping('start')]
public function start(): ResponseEntity
{
try {
return ResponseEntity::ok(['pid' => InboxWatcher::dispatch()]);
} catch (ProcessAlreadyRunningException $e) {
return ResponseEntity::conflict(['message' => $e->getMessage()]);
}
}
#[PostMapping('stop')]
public function stop(): ResponseEntity
{
return InboxWatcher::stop()
? ResponseEntity::accepted(['stopping' => true])
: ResponseEntity::notFound(['message' => 'The process is not running']);
}
}Stopping returns 202 rather than 200 on purpose: stop() only sends the signal,
and the process ends once it has finished the current unit of work.
Forbidding concurrent work
Sometimes two processes must not run at the same time — a queue handler and a one-off migration touching the same records, say. The “do not start a second copy of myself” check lives in its own method, and it can be extended:
use Main\Process\Migration;
class OutboxWorker extends Process
{
protected static function ensureNotRunning(): void
{
parent::ensureNotRunning(); // do not start a second copy of myself
Migration::ensureNotRunning(); // ...and do not start while a migration runs
}
}The method is called once before starting, in start() and dispatch(). If any
check fails, a ProcessAlreadyRunningException is thrown and the process simply
does not come up.
Before starting only, never in the body
It is tempting to put such a check where it runs continuously — in the loop, in a
daemon’s tick() or in its scaling(). Do not: an exception thrown from a running
process does not “cancel a start”, it brings down the already running process,
and on every turn at that.
Mutual exclusion is a start condition, which is why it lives in
ensureNotRunning().
Control from the console
php call process list # every process in the project and its state
php call process main.process.Worker # run in the current terminal
php call process main.process.Worker start -d # run in the background
php call process main.process.Worker stop # graceful stop (SIGTERM)
php call process main.process.Worker status # the state
php call process main.process.Worker status -v # plus CPU and memory usageThe class is named in dot notation: main.process.Worker expands to
Main\Process\Worker. The command has a short alias — call proc.
The status output shows the PID, the state, busyness, uptime and the
concurrent-task cap:
Process ● RUNNING
PID 48213
State RUNNING
Activity busy
Started 2026-08-11 14:02:31 +03:00
Uptime 2h 14m
Concurrency 20One instance per class
A process is a singleton: one class means one running instance. A second start
attempt receives a ProcessAlreadyRunningException carrying the PID of the one
already running.
If you need several workers of the same logic, that is a daemon with replicas, not several process starts.
Examples
Polling an external source
The simplest form: wake up, go and fetch, sleep.
<?php
namespace Main\Process;
use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Process\Stereotype\Process;
class RatesPoller extends Process
{
#[Autowired] private RateGateway $gateway;
#[Autowired] private RateRepository $rates;
protected ?string $processTitle = 'rates';
protected float $grace = 10.0;
public function run(): void
{
while ($this->isRunning()) {
$this->markBusy();
try {
$this->rates->saveAll($this->gateway->fetch());
} catch (\Throwable $e) {
$this->logger->error('rates poll failed', ['error' => $e->getMessage()]);
} finally {
$this->markIdle();
}
$this->sleep(60);
}
}
}Note the try/catch around the work: an exception escaping run() ends the
process. For recurring work the error almost always has to be caught, logged and
carried past.
An availability watcher
A process does not have to handle anything — it can simply watch. Here it polls a list of services and raises an alarm when one stops answering.
<?php
namespace Main\Process;
use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Process\InterruptedException;
use Flytachi\Winter\Kernel\Process\Stereotype\Process;
class HealthChecker extends Process
{
#[Autowired] private ServiceRegistry $registry;
#[Autowired] private HttpClient $http;
#[Autowired] private Alerts $alerts;
protected ?string $processTitle = 'health';
public function run(): void
{
while ($this->isRunning()) {
$this->markBusy();
foreach ($this->registry->all() as $service) {
$ok = $this->http->ping($service->url, timeout: 3);
if (!$ok && $service->wasHealthy()) {
$this->alerts->fire("{$service->name} is unreachable");
}
$this->registry->record($service, $ok);
}
$this->markIdle();
try {
$this->sleep(15);
} catch (InterruptedException) {
return; // stopped during the pause
}
}
}
}A queue handler
The classic case: a process works through accumulated jobs.
<?php
namespace Main\Process;
use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Process\Stereotype\Process;
class OutboxWorker extends Process
{
#[Autowired] private OutboxRepository $outbox;
#[Autowired] private Mailer $mailer;
protected float $grace = 30.0;
public function run(): void
{
while ($this->isRunning()) {
$letter = $this->outbox->takeNext();
if ($letter === null) {
$this->sleep(2);
continue;
}
$this->markBusy();
try {
$this->mailer->send($letter);
$this->outbox->markSent($letter->id);
} catch (\Throwable $e) {
$this->outbox->release($letter->id); // return it to the queue
$this->logger->error('send failed', ['id' => $letter->id]);
} finally {
$this->markIdle();
}
}
}
}The key detail is release() in the catch: a job taken from the queue has to
return to it on any failure, or it is lost along with the process.
You write the queue
The OutboxRepository in the example is your class, not part of the framework:
Winter provides no queue. Usually it is a database table with “state” and “claimed”
columns, over which you write takeNext(), markSent() and release(). How to
talk to the database is on the Repository page.
If several workers process the jobs at once, they have to be claimed atomically — see Daemons.
Parallel handling with a cap
For when the work is bound by network waits rather than by the CPU.
<?php
namespace Main\Process;
use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Process\Stereotype\Process;
class WebhookSender extends Process
{
#[Autowired] private WebhookRepository $hooks;
#[Autowired] private HttpClient $http;
protected int $concurrency = 50; // no more than 50 requests at once
protected float $grace = 60.0; // let what started be delivered
public function run(): void
{
while ($this->isRunning()) {
$batch = $this->hooks->takeBatch(100);
if ($batch === []) {
$this->sleep(1);
continue;
}
foreach ($batch as $hook) {
$this->spawn(function () use ($hook) {
$this->http->post($hook->url, $hook->payload);
$this->hooks->markDelivered($hook->id);
});
}
}
}
}The $concurrency cap is mandatory here: without it a batch of a hundred thousand
records would try to go out at once. And a minute of $grace lets deliveries in
flight finish when the process is stopped.
Releasing resources after a fork
When a process runs as a daemon worker it is created by a fork — and inherits the parent’s connections, which must not be used.
protected function afterFork(): void
{
parent::afterFork(); // mandatory: resets the framework's pools
$this->rabbit->reconnect(); // and your own
}Calling parent::afterFork() first is not a formality: that is what reopens the
database connections. For a standalone process this hook is never called — there is
no fork.
Next
- Daemons — several workers of the same logic under supervision
- Components — where
#[EnableProcess]is declared - Scheduler — when the work is tied to the clock, not to a queue
- Async calls — parallelism inside one task
- Dependency injection — how a process receives services