Skip to content

Application · Adapter

Process

Describe commands through a portable application model, then run them with explicit concurrency, retry, failure, and output policy.

Application code describes commands and policy; the adapter owns operating-system process execution.

Process separates the description of a command from the mechanism that launches it. Application code creates immutable Process descriptors and depends on ProcessRunner; the composition root selects an adapter and decides concurrency, retry, logging, and output policy.

Ownership. Process, ProcessBuilder, ProcessRunner, and ProcessErrorBehavior belong to the Application layer. SymfonyProcessRunner is an Adapter implementation backed by Symfony Process. The consuming application owns command allowlisting, worker capacity, retry suitability, timeout budgets, and the destination and retention of process output.

Dependencies. Descriptor construction and the runner port require PHP 8.5+ and this package. Install symfony/process for the supplied runner and provide a PSR-3 logger only when execution events should be logged.

Install.

composer require johnnickell/fight-common
composer require symfony/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)
shellCommand(string $command) Preserves an already accepted shell command, including operators and quoting
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

Use shellCommand() only when the entire command string is already trusted, such as a command accepted by Scheduler configuration. For untrusted or independently supplied arguments, use prefix(), arg(), option(), and short() so each token is escaped.


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 started when run() is called. run() blocks until the queue and active set are empty, then clears the runner. A fail-fast exception can interrupt that normal completion path; discard the runner after an exception rather than treating its queued state as reusable. 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);  // continue after non-zero exit; configured logging still applies

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();

Operational guidance

  • Prefer prefix(), arg(), option(), and short() whenever any token comes from configuration, a request, or another independently supplied value. Use shellCommand() only for a complete, already trusted command that intentionally needs shell syntax.
  • Set a finite timeout unless the command is deliberately unbounded. timeout(null) disables the Symfony Process timeout and transfers termination responsibility to the consumer.
  • Choose RETRY only for idempotent operations. The runner repeats the whole process descriptor; it cannot determine whether a failed attempt already produced a side effect.
  • maxConcurrent: 0 is unbounded. Size concurrency against CPU, memory, file descriptors, remote rate limits, and downstream connection pools.
  • Output callbacks receive chunks, not guaranteed complete lines. Redact secrets before logging, and do not both disable output and expect callbacks or captured failure output.