Application · Adapter
Process
Describe commands through a portable application model, then run them with explicit concurrency, retry, failure, and output policy.
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.
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¶
- Process
- ProcessBuilder
- ProcessRunner
- ProcessErrorBehavior
- SymfonyProcessRunner
- Symfony Configuration
- 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(), andshort()whenever any token comes from configuration, a request, or another independently supplied value. UseshellCommand()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
RETRYonly for idempotent operations. The runner repeats the whole process descriptor; it cannot determine whether a failed attempt already produced a side effect. maxConcurrent: 0is 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.