Background components

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.

Base Process\Stereotype\ProcessDeclared by #[EnableProcess]Command call process

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 SIGTERM the 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():

main/Process/InboxWatcher.php
<?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:

bootstrap.php
#[EnableWeb]
#[EnableProcess(\Main\Process\InboxWatcher::class)]
final class Application extends WinterApplication { /* ... */ }

Or start it on its own, without the application:

bash
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 gracefully

The 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():

php
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:

php
$this->sleep(1.5);   // seconds, fractions allowed

It 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.

php
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:

  1. A busy process is not interrupted mid-work — the stop waits for markIdle().
  2. Busyness is visible in call process status and is taken into account by a daemon’s supervisor.
php
$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:

php
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:

php
$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:

php
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:

php
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:

php
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:

php
protected int $concurrency = 20;   // 0 (the default) — no limit

The 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:

php
$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
php
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:

php
protected ?string $processTitle = 'inbox-watcher';
text
winter-proc: inbox-watcher

When 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
php
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

php
use Main\Process\InboxWatcher;

$pid = InboxWatcher::dispatch();          // went into the background, returned a PID

InboxWatcher::stop();                     // graceful stop: true if the signal was sent

The dispatch() argument decides where the child’s output goes. By default it is discarded; pass a path to keep it:

php
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:

php
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:

php
$status = InboxWatcher::status(usage: true);

$status->usage?->cpu;      // CPU percentage
$status->usage?->rssKb;    // resident memory, KB
$status->usage?->elapsed;  // how long it has been running

The 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

main/Admin/ProcessController.php
<?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:

php
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

bash
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 usage

The 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:

text
Process ● RUNNING
PID          48213
State        RUNNING
Activity     busy
Started      2026-08-11 14:02:31 +03:00
Uptime       2h 14m
Concurrency  20

One 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.

main/Process/RatesPoller.php
<?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.

main/Process/HealthChecker.php
<?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.

main/Process/OutboxWorker.php
<?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.

main/Process/WebhookSender.php
<?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.

php
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