Skip to content

Process

A port-and-adapter layer for running shell processes. Process describes what to run; ProcessRunner manages a queue of processes, controls concurrency, and handles failures. SymfonyProcessRunner is the concrete adapter backed by symfony/process.

Application\Process
├── Process                             — Immutable process descriptor
├── ProcessBuilder                      — Fluent builder for Process
├── ProcessRunner (interface)           — attach(), clear(), run()
├── ProcessErrorBehavior (enum: int)    — EXCEPTION, IGNORE, RETRY
└── Exception\
    ├── ProcessException                — extends SystemException
    └── ProcessFailedException          — extends ProcessException

Adapter\Process
└── Symfony\
    └── SymfonyProcessRunner            — Concurrent runner via symfony/process

Table of Contents

  1. Process
  2. ProcessBuilder
  3. ProcessRunner
  4. ProcessErrorBehavior
  5. SymfonyProcessRunner
  6. Symfony Configuration
  7. Usage Examples

Process Descriptor

Fight\Common\Application\Process\Process

An immutable value object that describes a process to be run. All fields beyond $command are optional.

use Fight\Common\Application\Process\Process;

$process = new Process(
    command:        'bin/console cache:clear',
    directory:      '/var/www/html',
    environment:    ['APP_ENV' => 'prod'],
    input:          null,
    timeout:        60.0,          // seconds; null = no timeout (default 60.0)
    stdout:         $stdoutFn,     // callable(?string $data): void
    stderr:         $stderrFn,     // callable(?string $data): void
    outputDisabled: false
);

Fields

Method Returns Description
command() string Shell command string
directory() ?string Working directory (null = inherit)
environment() ?array<string, string> Additional env vars (null = inherit)
input() mixed Stdin input (string, resource, or null)
timeout() ?float Timeout in seconds (null = unlimited)
stdout() callable\|null Called for each chunk of stdout output
stderr() callable\|null Called for each chunk of stderr output
isOutputDisabled() bool Whether output capturing is disabled

ProcessBuilder

Fight\Common\Application\Process\ProcessBuilder

A fluent builder for constructing Process descriptors. Accepts an optional initial argument list in the constructor (or via create()), then assembles the shell command using escapeshellarg so that arguments with spaces or special characters are always safe.

use Fight\Common\Application\Process\ProcessBuilder;

$process = ProcessBuilder::create('vendor/bin/phpunit')
    ->option('filter', 'test_my_feature')
    ->option('no-coverage')
    ->directory('/var/www/html')
    ->timeout(120)
    ->stdout(fn(string $data) => print($data))
    ->getProcess();

Building the Command

The builder distinguishes between a prefix (fixed leading tokens, e.g. the executable) and arguments (variable tokens appended after the prefix). Both are escaped and joined with spaces when getProcess() is called.

// Prefix + arguments
$process = ProcessBuilder::create()
    ->prefix(['php', 'artisan'])   // fixed executable tokens
    ->arg('migrate')               // positional argument
    ->option('force')              // --force
    ->short('n')                   // -n
    ->getProcess();
// → 'php' 'artisan' 'migrate' '--force' '-n'

Methods

Method Description
create(string\|array\|null $args) Static factory; accepts an initial argument string or list
prefix(string\|array $prefix) Sets fixed leading tokens (replaces any previous prefix)
arg(string $arg) Appends a positional argument; empty strings are ignored
option(string $option, ?string $value) Appends a long option; -- added if absent
short(string $option, ?string $value) Appends a short option; - added if absent
clearArgs() Removes all positional arguments (prefix unaffected)
directory(?string $dir) Sets working directory
input(mixed $input) Sets stdin; accepts string, resource, or null
timeout(int\|float\|null $s) Sets timeout in seconds (default 60.0); null = unlimited
setEnv(string $name, string $value) Adds or overrides an environment variable
stdout(callable\|null $fn) Sets callback for stdout chunks
stderr(callable\|null $fn) Sets callback for stderr chunks
disableOutput() Disables output capturing
enableOutput() Re-enables output capturing
getProcess() Returns the built Process; throws MethodCallException if empty

ProcessRunner

Fight\Common\Application\Process\ProcessRunner

interface ProcessRunner
{
    public function attach(Process $process): void;

    public function clear(): void;

    /**
     * @throws ProcessException
     */
    public function run(?ProcessErrorBehavior $errorBehavior = null): void;
}

Processes are queued via attach(), then all started when run() is called. run() blocks until all queued processes complete and then clears the queue automatically. Pass a ProcessErrorBehavior to control how failures are handled (defaults to EXCEPTION).


ProcessErrorBehavior

Fight\Common\Application\Process\ProcessErrorBehavior

enum ProcessErrorBehavior: int
{
    case EXCEPTION = 1;   // Throw ProcessFailedException on non-zero exit (default)
    case IGNORE    = 2;   // Continue silently when a process fails
    case RETRY     = 3;   // Re-run failed processes up to the configured tries limit
}

SymfonyProcessRunner

Fight\Common\Adapter\Process\Symfony\SymfonyProcessRunner

A queue-based, concurrent process runner built on symfony/process. Processes are started up to the concurrency limit; as they finish, the next queued process is launched.

use Fight\Common\Adapter\Process\Symfony\SymfonyProcessRunner;

$runner = new SymfonyProcessRunner(
    logger:        $logger,       // ?LoggerInterface (default null)
    maxConcurrent: 4,             // max simultaneous processes; 0 = unlimited (default 1)
    delay:         1000,          // microseconds between polling ticks (default 1000)
    tries:         3,             // max attempts per process when using RETRY (default 3)
    logLevel:      LogLevel::DEBUG
);

Concurrency

Set maxConcurrent to control how many processes run at the same time:

$runner = new SymfonyProcessRunner(maxConcurrent: 8);

foreach ($jobs as $job) {
    $runner->attach(new Process($job->command()));
}

$runner->run();  // runs up to 8 at a time, blocks until all complete

maxConcurrent: 0 disables the concurrency limit — all queued processes are started immediately.

Retry

When ProcessErrorBehavior::RETRY is passed to run(), a failed process is re-enqueued and started again, up to $tries total attempts:

$runner = new SymfonyProcessRunner(tries: 5);

$runner->attach(new Process('bin/flaky-script'));
$runner->run(ProcessErrorBehavior::RETRY);
// attempts up to 5 times before throwing ProcessFailedException

Logging

When a LoggerInterface is provided, the runner logs: - Process started (at configured $logLevel) - Process restarted after failure (at configured $logLevel) - Process failed — includes exit code, exit code text, stdout, and stderr (at error)

Output Callbacks

Attach per-process callbacks to stream output in real time:

$process = ProcessBuilder::create('bin/long-running-task')
    ->stdout(fn(string $data) => $this->logger->info($data))
    ->stderr(fn(string $data) => $this->logger->error($data))
    ->getProcess();

Symfony Configuration

# config/packages/common_process.yaml

services:
    _defaults:
        autowire: true
        autoconfigure: true

    Fight\Common\Adapter\Process\Symfony\SymfonyProcessRunner:
        arguments:
            - '@logger'
            - 4        # maxConcurrent
            - 1000     # delay (µs)
            - 3        # tries

    Fight\Common\Application\Process\ProcessRunner:
        alias: Fight\Common\Adapter\Process\Symfony\SymfonyProcessRunner

Usage Examples

Running a Single Process

use Fight\Common\Application\Process\ProcessBuilder;
use Fight\Common\Application\Process\ProcessRunner;

class CacheClearer
{
    public function __construct(private ProcessRunner $runner) {}

    public function clear(string $env): void
    {
        $this->runner->attach(
            ProcessBuilder::create('bin/console')
                ->arg('cache:clear')
                ->directory('/var/www/html')
                ->setEnv('APP_ENV', $env)
                ->getProcess()
        );

        $this->runner->run();
    }
}

Running Jobs in Parallel

$runner = new SymfonyProcessRunner(maxConcurrent: 4);

foreach ($files as $file) {
    $runner->attach(
        ProcessBuilder::create('bin/process-file')
            ->arg($file)
            ->getProcess()
    );
}

$runner->run();  // 4 at a time until all complete

Ignoring Failures

$runner->attach(new Process('bin/optional-cleanup'));
$runner->run(ProcessErrorBehavior::IGNORE);  // non-zero exit silently discarded

Retrying Flaky Processes

$runner = new SymfonyProcessRunner(tries: 3);
$runner->attach(
    ProcessBuilder::create('curl')
        ->arg('https://api.example.com/sync')
        ->getProcess()
);
$runner->run(ProcessErrorBehavior::RETRY);
// retries up to 3 times; throws ProcessFailedException if all attempts fail

Streaming Output

$lines = [];

$runner->attach(
    ProcessBuilder::create('bin/generate-report')
        ->stdout(function (string $data) use (&$lines): void {
            $lines[] = trim($data);
        })
        ->getProcess()
);

$runner->run();