├── src ├── Exception │ ├── ProcessException.php │ ├── ProcessStartException.php │ └── ProcessTimeoutException.php ├── Output │ ├── NullOutputHandler.php │ ├── OutputHandlerInterface.php │ ├── HtmlOutputHandler.php │ └── ConsoleOutputHandler.php ├── ProcessResult.php ├── Process.php └── ProcessManager.php ├── LICENSE ├── composer.json └── README.md /src/Exception/ProcessException.php: -------------------------------------------------------------------------------- 1 | output("[QUEUED] {$script}"); 20 | } 21 | 22 | public function scriptCompleted(string $script): void 23 | { 24 | $this->output("[DONE] {$script}"); 25 | } 26 | 27 | public function scriptKilled(string $script): void 28 | { 29 | $this->output("[KILLED] {$script}"); 30 | } 31 | 32 | public function info(string $message): void 33 | { 34 | $this->output("[INFO] {$message}"); 35 | } 36 | 37 | public function error(string $message): void 38 | { 39 | $this->output("[ERROR] {$message}"); 40 | } 41 | 42 | private function output(string $message): void 43 | { 44 | echo $message . "
\n"; 45 | 46 | if ($this->flush) { 47 | if (ob_get_level() > 0) { 48 | ob_flush(); 49 | } 50 | flush(); 51 | } 52 | } 53 | } 54 | 55 | -------------------------------------------------------------------------------- /src/ProcessResult.php: -------------------------------------------------------------------------------- 1 | wasKilled; 29 | } 30 | 31 | /** 32 | * Check if the process had any error output. 33 | */ 34 | public function hasErrors(): bool 35 | { 36 | return $this->errorOutput !== ''; 37 | } 38 | 39 | /** 40 | * Convert the result to an array. 41 | * 42 | * @return array{script: string, exitCode: int, output: string, errorOutput: string, elapsedTime: int, wasKilled: bool, wasSuccessful: bool} 43 | */ 44 | public function toArray(): array 45 | { 46 | return [ 47 | 'script' => $this->script, 48 | 'exitCode' => $this->exitCode, 49 | 'output' => $this->output, 50 | 'errorOutput' => $this->errorOutput, 51 | 'elapsedTime' => $this->elapsedTime, 52 | 'wasKilled' => $this->wasKilled, 53 | 'wasSuccessful' => $this->wasSuccessful, 54 | ]; 55 | } 56 | } 57 | 58 | -------------------------------------------------------------------------------- /src/Output/ConsoleOutputHandler.php: -------------------------------------------------------------------------------- 1 | writeLine( 26 | $this->colorize("[QUEUED]", self::COLOR_YELLOW) . " {$script}" 27 | ); 28 | } 29 | 30 | public function scriptCompleted(string $script): void 31 | { 32 | $this->writeLine( 33 | $this->colorize("[DONE]", self::COLOR_GREEN) . " {$script}" 34 | ); 35 | } 36 | 37 | public function scriptKilled(string $script): void 38 | { 39 | $this->writeLine( 40 | $this->colorize("[KILLED]", self::COLOR_RED) . " {$script}" 41 | ); 42 | } 43 | 44 | public function info(string $message): void 45 | { 46 | $this->writeLine( 47 | $this->colorize("[INFO]", self::COLOR_CYAN) . " {$message}" 48 | ); 49 | } 50 | 51 | public function error(string $message): void 52 | { 53 | $this->writeLine( 54 | $this->colorize("[ERROR]", self::COLOR_RED) . " {$message}" 55 | ); 56 | } 57 | 58 | private function colorize(string $text, string $color): string 59 | { 60 | if (!$this->useColors) { 61 | return $text; 62 | } 63 | 64 | return $color . $text . self::COLOR_RESET; 65 | } 66 | 67 | private function writeLine(string $message): void 68 | { 69 | echo $message . PHP_EOL; 70 | } 71 | } 72 | 73 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dalehurley/process-manager", 3 | "description": "A lightweight parallel process runner for PHP. Execute multiple scripts concurrently with configurable parallelism, timeouts, and result tracking.", 4 | "type": "library", 5 | "license": "MIT", 6 | "keywords": [ 7 | "process", 8 | "manager", 9 | "multi-process", 10 | "concurrent", 11 | "parallel", 12 | "background", 13 | "tasks", 14 | "worker", 15 | "pool", 16 | "async", 17 | "batch", 18 | "cli" 19 | ], 20 | "homepage": "https://github.com/dalehurley/PHP-Process-Manager", 21 | "authors": [ 22 | { 23 | "name": "Dale Hurley", 24 | "homepage": "https://dalehurley.com", 25 | "role": "Developer" 26 | } 27 | ], 28 | "support": { 29 | "issues": "https://github.com/dalehurley/PHP-Process-Manager/issues", 30 | "source": "https://github.com/dalehurley/PHP-Process-Manager" 31 | }, 32 | "require": { 33 | "php": ">=8.2", 34 | "ext-pcntl": "*" 35 | }, 36 | "suggest": { 37 | "ext-posix": "For improved process management capabilities" 38 | }, 39 | "require-dev": { 40 | "phpunit/phpunit": "^10.0 || ^11.0", 41 | "phpstan/phpstan": "^1.10 || ^2.0" 42 | }, 43 | "autoload": { 44 | "psr-4": { 45 | "DaleHurley\\ProcessManager\\": "src/" 46 | } 47 | }, 48 | "autoload-dev": { 49 | "psr-4": { 50 | "DaleHurley\\ProcessManager\\Tests\\": "tests/" 51 | } 52 | }, 53 | "scripts": { 54 | "test": "phpunit", 55 | "test:coverage": "phpunit --coverage-html coverage", 56 | "analyse": "phpstan analyse src --level=8", 57 | "check": [ 58 | "@analyse", 59 | "@test" 60 | ] 61 | }, 62 | "scripts-descriptions": { 63 | "test": "Run PHPUnit test suite", 64 | "test:coverage": "Run tests with HTML coverage report", 65 | "analyse": "Run PHPStan static analysis", 66 | "check": "Run all checks (analysis + tests)" 67 | }, 68 | "config": { 69 | "sort-packages": true, 70 | "preferred-install": "dist", 71 | "optimize-autoloader": true 72 | }, 73 | "minimum-stability": "stable", 74 | "prefer-stable": true 75 | } 76 | -------------------------------------------------------------------------------- /src/Process.php: -------------------------------------------------------------------------------- 1 | */ 18 | private array $pipes = []; 19 | 20 | private int $startTime; 21 | 22 | private bool $closed = false; 23 | 24 | /** 25 | * @param array $arguments Command-line arguments for the process 26 | * @param array $environment Environment variables for the process 27 | * @throws ProcessStartException If the process fails to start 28 | */ 29 | public function __construct( 30 | public readonly string $executable, 31 | public readonly string $script, 32 | public readonly string $workingDirectory = '', 33 | public readonly int $maxExecutionTime = 300, 34 | public readonly array $arguments = [], 35 | array $environment = [] 36 | ) { 37 | $this->start($environment); 38 | } 39 | 40 | /** 41 | * Check if the process is still running. 42 | */ 43 | public function isRunning(): bool 44 | { 45 | if ($this->closed || !is_resource($this->resource)) { 46 | return false; 47 | } 48 | 49 | $status = proc_get_status($this->resource); 50 | return $status['running']; 51 | } 52 | 53 | /** 54 | * Check if the process has exceeded its maximum execution time. 55 | */ 56 | public function hasExceededTimeout(): bool 57 | { 58 | return (time() - $this->startTime) > $this->maxExecutionTime; 59 | } 60 | 61 | /** 62 | * Get the elapsed time in seconds since the process started. 63 | */ 64 | public function getElapsedTime(): int 65 | { 66 | return time() - $this->startTime; 67 | } 68 | 69 | /** 70 | * Get the process ID if available. 71 | */ 72 | public function getPid(): ?int 73 | { 74 | if ($this->closed || !is_resource($this->resource)) { 75 | return null; 76 | } 77 | 78 | $status = proc_get_status($this->resource); 79 | return $status['pid']; 80 | } 81 | 82 | /** 83 | * Get the exit code of the process (if completed). 84 | */ 85 | public function getExitCode(): ?int 86 | { 87 | if (!is_resource($this->resource)) { 88 | return null; 89 | } 90 | 91 | $status = proc_get_status($this->resource); 92 | if ($status['running']) { 93 | return null; 94 | } 95 | 96 | return $status['exitcode']; 97 | } 98 | 99 | /** 100 | * Read output from the process (stdout). 101 | */ 102 | public function getOutput(): string 103 | { 104 | if (!isset($this->pipes[1]) || !is_resource($this->pipes[1])) { 105 | return ''; 106 | } 107 | 108 | stream_set_blocking($this->pipes[1], false); 109 | return stream_get_contents($this->pipes[1]) ?: ''; 110 | } 111 | 112 | /** 113 | * Read error output from the process (stderr). 114 | */ 115 | public function getErrorOutput(): string 116 | { 117 | if (!isset($this->pipes[2]) || !is_resource($this->pipes[2])) { 118 | return ''; 119 | } 120 | 121 | stream_set_blocking($this->pipes[2], false); 122 | return stream_get_contents($this->pipes[2]) ?: ''; 123 | } 124 | 125 | /** 126 | * Terminate the process forcefully. 127 | */ 128 | public function terminate(int $signal = 15): bool 129 | { 130 | if ($this->closed || !is_resource($this->resource)) { 131 | return false; 132 | } 133 | 134 | return proc_terminate($this->resource, $signal); 135 | } 136 | 137 | /** 138 | * Close the process and clean up resources. 139 | */ 140 | public function close(): int 141 | { 142 | if ($this->closed) { 143 | return -1; 144 | } 145 | 146 | $this->closed = true; 147 | 148 | // Close all pipes 149 | foreach ($this->pipes as $pipe) { 150 | if (is_resource($pipe)) { 151 | fclose($pipe); 152 | } 153 | } 154 | 155 | if (is_resource($this->resource)) { 156 | return proc_close($this->resource); 157 | } 158 | 159 | return -1; 160 | } 161 | 162 | /** 163 | * Get the full command that was executed. 164 | */ 165 | public function getCommand(): string 166 | { 167 | $parts = [$this->executable]; 168 | 169 | if ($this->workingDirectory !== '') { 170 | $parts[] = $this->workingDirectory . DIRECTORY_SEPARATOR . $this->script; 171 | } else { 172 | $parts[] = $this->script; 173 | } 174 | 175 | foreach ($this->arguments as $argument) { 176 | $parts[] = escapeshellarg($argument); 177 | } 178 | 179 | return implode(' ', $parts); 180 | } 181 | 182 | /** 183 | * Start the process. 184 | * 185 | * @param array $environment 186 | * @throws ProcessStartException 187 | */ 188 | private function start(array $environment): void 189 | { 190 | $descriptorSpec = [ 191 | 0 => ['pipe', 'r'], // stdin 192 | 1 => ['pipe', 'w'], // stdout 193 | 2 => ['pipe', 'w'], // stderr 194 | ]; 195 | 196 | $command = $this->getCommand(); 197 | $cwd = $this->workingDirectory !== '' ? $this->workingDirectory : null; 198 | $env = !empty($environment) ? array_merge($_ENV, $environment) : null; 199 | 200 | // Suppress warning since we handle failure gracefully via exception 201 | $this->resource = @proc_open($command, $descriptorSpec, $this->pipes, $cwd, $env); 202 | 203 | if ($this->resource === false) { 204 | throw new ProcessStartException($this->script, 'proc_open failed'); 205 | } 206 | 207 | $this->startTime = time(); 208 | 209 | // Set stdout and stderr to non-blocking 210 | if (isset($this->pipes[1])) { 211 | stream_set_blocking($this->pipes[1], false); 212 | } 213 | if (isset($this->pipes[2])) { 214 | stream_set_blocking($this->pipes[2], false); 215 | } 216 | } 217 | 218 | /** 219 | * Ensure resources are cleaned up on destruction. 220 | */ 221 | public function __destruct() 222 | { 223 | $this->close(); 224 | } 225 | } 226 | 227 | -------------------------------------------------------------------------------- /src/ProcessManager.php: -------------------------------------------------------------------------------- 1 | addScript('worker.php', maxExecutionTime: 60); 26 | * $manager->addScript('task.php', maxExecutionTime: 30); 27 | * 28 | * $results = $manager->run(); 29 | * ``` 30 | */ 31 | final class ProcessManager 32 | { 33 | /** @var array, environment: array}> */ 34 | private array $queue = []; 35 | 36 | /** @var array */ 37 | private array $running = []; 38 | 39 | /** @var array */ 40 | private array $results = []; 41 | 42 | private OutputHandlerInterface $outputHandler; 43 | 44 | /** 45 | * Create a new ProcessManager instance. 46 | * 47 | * @param string $executable The command to execute (e.g., 'php', 'python') 48 | * @param string $workingDirectory The directory to run scripts from 49 | * @param int $maxConcurrentProcesses Maximum number of processes to run simultaneously 50 | * @param int $sleepInterval Seconds to wait between checking process status 51 | * @param OutputHandlerInterface|null $outputHandler Handler for output messages 52 | */ 53 | public function __construct( 54 | private string $executable = 'php', 55 | private string $workingDirectory = '', 56 | private int $maxConcurrentProcesses = 3, 57 | private int $sleepInterval = 1, 58 | ?OutputHandlerInterface $outputHandler = null 59 | ) { 60 | $this->outputHandler = $outputHandler ?? new NullOutputHandler(); 61 | } 62 | 63 | /** 64 | * Set the executable command. 65 | */ 66 | public function setExecutable(string $executable): self 67 | { 68 | $this->executable = $executable; 69 | return $this; 70 | } 71 | 72 | /** 73 | * Set the working directory for scripts. 74 | */ 75 | public function setWorkingDirectory(string $workingDirectory): self 76 | { 77 | $this->workingDirectory = $workingDirectory; 78 | return $this; 79 | } 80 | 81 | /** 82 | * Set the maximum number of concurrent processes. 83 | */ 84 | public function setMaxConcurrentProcesses(int $count): self 85 | { 86 | $this->maxConcurrentProcesses = max(1, $count); 87 | return $this; 88 | } 89 | 90 | /** 91 | * Set the sleep interval between status checks. 92 | */ 93 | public function setSleepInterval(int $seconds): self 94 | { 95 | $this->sleepInterval = max(0, $seconds); 96 | return $this; 97 | } 98 | 99 | /** 100 | * Set the output handler. 101 | */ 102 | public function setOutputHandler(OutputHandlerInterface $handler): self 103 | { 104 | $this->outputHandler = $handler; 105 | return $this; 106 | } 107 | 108 | /** 109 | * Add a script to the execution queue. 110 | * 111 | * @param string $script The script filename or path 112 | * @param int $maxExecutionTime Maximum time in seconds before the process is killed 113 | * @param array $arguments Additional command-line arguments 114 | * @param array $environment Environment variables for the process 115 | */ 116 | public function addScript( 117 | string $script, 118 | int $maxExecutionTime = 300, 119 | array $arguments = [], 120 | array $environment = [] 121 | ): self { 122 | $this->queue[] = [ 123 | 'script' => $script, 124 | 'maxExecutionTime' => $maxExecutionTime, 125 | 'arguments' => $arguments, 126 | 'environment' => $environment, 127 | ]; 128 | return $this; 129 | } 130 | 131 | /** 132 | * Add multiple scripts to the execution queue. 133 | * 134 | * @param array, environment?: array}> $scripts 135 | */ 136 | public function addScripts(array $scripts): self 137 | { 138 | foreach ($scripts as $script) { 139 | if (is_string($script)) { 140 | $this->addScript($script); 141 | } else { 142 | $this->addScript( 143 | $script['script'], 144 | $script['maxExecutionTime'] ?? 300, 145 | $script['arguments'] ?? [], 146 | $script['environment'] ?? [] 147 | ); 148 | } 149 | } 150 | return $this; 151 | } 152 | 153 | /** 154 | * Get the number of scripts in the queue. 155 | */ 156 | public function getQueueCount(): int 157 | { 158 | return count($this->queue); 159 | } 160 | 161 | /** 162 | * Get the number of currently running processes. 163 | */ 164 | public function getRunningCount(): int 165 | { 166 | return count($this->running); 167 | } 168 | 169 | /** 170 | * Clear the execution queue. 171 | */ 172 | public function clearQueue(): self 173 | { 174 | $this->queue = []; 175 | return $this; 176 | } 177 | 178 | /** 179 | * Run all queued scripts and return results. 180 | * 181 | * @return array Results for each completed process 182 | */ 183 | public function run(): array 184 | { 185 | $this->results = []; 186 | $queueIndex = 0; 187 | $totalScripts = count($this->queue); 188 | 189 | while (true) { 190 | // Start new processes up to the limit 191 | while (count($this->running) < $this->maxConcurrentProcesses && $queueIndex < $totalScripts) { 192 | $task = $this->queue[$queueIndex]; 193 | 194 | try { 195 | $process = new Process( 196 | executable: $this->executable, 197 | script: $task['script'], 198 | workingDirectory: $this->workingDirectory, 199 | maxExecutionTime: $task['maxExecutionTime'], 200 | arguments: $task['arguments'], 201 | environment: $task['environment'] 202 | ); 203 | 204 | $this->running[$queueIndex] = $process; 205 | $this->outputHandler->scriptAdded($task['script']); 206 | } catch (ProcessStartException $e) { 207 | $this->outputHandler->error($e->getMessage()); 208 | $this->results[$queueIndex] = new ProcessResult( 209 | script: $task['script'], 210 | exitCode: -1, 211 | output: '', 212 | errorOutput: $e->getMessage(), 213 | elapsedTime: 0, 214 | wasKilled: false, 215 | wasSuccessful: false 216 | ); 217 | } 218 | 219 | $queueIndex++; 220 | } 221 | 222 | // Check if we're done 223 | if (count($this->running) === 0 && $queueIndex >= $totalScripts) { 224 | break; 225 | } 226 | 227 | // Wait before checking status 228 | if ($this->sleepInterval > 0) { 229 | sleep($this->sleepInterval); 230 | } 231 | 232 | // Check running processes 233 | $this->checkRunningProcesses(); 234 | } 235 | 236 | return $this->results; 237 | } 238 | 239 | /** 240 | * Check all running processes and handle completed/timed-out ones. 241 | */ 242 | private function checkRunningProcesses(): void 243 | { 244 | foreach ($this->running as $index => $process) { 245 | $isRunning = $process->isRunning(); 246 | $hasTimedOut = $process->hasExceededTimeout(); 247 | 248 | if (!$isRunning || $hasTimedOut) { 249 | if ($hasTimedOut && $isRunning) { 250 | // Kill the process 251 | $process->terminate(); 252 | $this->outputHandler->scriptKilled($process->script); 253 | 254 | $this->results[$index] = new ProcessResult( 255 | script: $process->script, 256 | exitCode: -1, 257 | output: $process->getOutput(), 258 | errorOutput: $process->getErrorOutput(), 259 | elapsedTime: $process->getElapsedTime(), 260 | wasKilled: true, 261 | wasSuccessful: false 262 | ); 263 | } else { 264 | // Process completed normally 265 | $this->outputHandler->scriptCompleted($process->script); 266 | 267 | $exitCode = $process->getExitCode() ?? -1; 268 | $this->results[$index] = new ProcessResult( 269 | script: $process->script, 270 | exitCode: $exitCode, 271 | output: $process->getOutput(), 272 | errorOutput: $process->getErrorOutput(), 273 | elapsedTime: $process->getElapsedTime(), 274 | wasKilled: false, 275 | wasSuccessful: $exitCode === 0 276 | ); 277 | } 278 | 279 | $process->close(); 280 | unset($this->running[$index]); 281 | } 282 | } 283 | } 284 | } 285 | 286 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP Process Manager 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/dalehurley/process-manager.svg)](https://packagist.org/packages/dalehurley/process-manager) 4 | [![Total Downloads](https://img.shields.io/packagist/dt/dalehurley/process-manager.svg)](https://packagist.org/packages/dalehurley/process-manager) 5 | [![PHP Version](https://img.shields.io/packagist/php-v/dalehurley/process-manager.svg)](https://packagist.org/packages/dalehurley/process-manager) 6 | [![License](https://img.shields.io/packagist/l/dalehurley/process-manager.svg)](LICENSE) 7 | [![Tests](https://img.shields.io/github/actions/workflow/status/dalehurley/PHP-Process-Manager/tests.yml?branch=master&label=tests)](https://github.com/dalehurley/PHP-Process-Manager/actions) 8 | 9 | A lightweight **parallel process runner** for PHP. Execute multiple scripts or commands concurrently with configurable parallelism, timeouts, and real-time output tracking. 10 | 11 | ``` 12 | Sequential execution: Parallel execution (3 concurrent): 13 | ┌────┐┌────┐┌────┐┌────┐┌────┐┌────┐ ┌────┐┌────┐ 14 | │ T1 ││ T2 ││ T3 ││ T4 ││ T5 ││ T6 │ │ T1 ││ T4 │ 15 | │ 5s ││ 5s ││ 5s ││ 5s ││ 5s ││ 5s │ │ 5s ││ 5s │ 16 | └────┘└────┘└────┘└────┘└────┘└────┘ ├────┤├────┤ 17 | Total: 30 seconds │ T2 ││ T5 │ 18 | │ 5s ││ 5s │ 19 | ├────┤├────┤ 20 | │ T3 ││ T6 │ 21 | │ 5s ││ 5s │ 22 | └────┘└────┘ 23 | Total: 10 seconds 24 | ``` 25 | 26 | --- 27 | 28 | ## Overview 29 | 30 | ### What is it? 31 | 32 | PHP Process Manager is a **concurrent task runner** that spawns and manages multiple OS processes simultaneously. Instead of running tasks one after another (sequential), it executes them in parallel—dramatically reducing total execution time for batch operations. 33 | 34 | Think of it as a simple **process pool** or **worker spawner**: you queue up scripts, set a concurrency limit, and the manager handles execution, monitoring, timeouts, and result collection. 35 | 36 | ### Who is it for? 37 | 38 | | Audience | Use Case | 39 | | ------------------------- | ------------------------------------------------------------- | 40 | | **Backend developers** | Batch processing, data imports/exports, scheduled jobs | 41 | | **DevOps engineers** | Deployment scripts, server maintenance, multi-host operations | 42 | | **Data engineers** | ETL pipelines, file processing, API data collection | 43 | | **QA engineers** | Parallel test execution, load testing preparation | 44 | | **System administrators** | Bulk operations, log processing, backup scripts | 45 | 46 | ### Why use it? 47 | 48 | PHP is single-threaded by default. When you have independent tasks, running them sequentially wastes time: 49 | 50 | | Scenario | Sequential | Parallel (5 workers) | Speedup | 51 | | -------------------------- | ---------- | -------------------- | ------- | 52 | | 10 API calls × 2s each | 20s | ~4s | **5×** | 53 | | 100 file imports × 1s each | 100s | ~20s | **5×** | 54 | | 50 email sends × 0.5s each | 25s | ~5s | **5×** | 55 | 56 | **This package is ideal when you need to:** 57 | 58 | - Run the same script multiple times with different inputs 59 | - Execute multiple independent scripts as part of a workflow 60 | - Process batches of work faster by parallelising 61 | - Add timeout protection to unreliable external calls 62 | - Limit concurrency to avoid overwhelming external services 63 | 64 | ### When to use it (and when not to) 65 | 66 | ✅ **Good fit:** 67 | 68 | - Tasks are **independent** and don't share state 69 | - Each task can run as a **separate PHP script or CLI command** 70 | - You need **timeout protection** for unreliable tasks 71 | - You want to **limit concurrency** (e.g., max 5 API calls at once) 72 | - Tasks take **seconds to minutes** to complete 73 | 74 | ❌ **Consider alternatives for:** 75 | 76 | - Tasks requiring **shared memory** or real-time inter-process communication → use [parallel](https://www.php.net/manual/en/book.parallel.php) extension 77 | - **Web request handling** with high throughput → use a message queue (Redis, RabbitMQ, SQS) 78 | - **Sub-second task spawning** at high frequency → process overhead becomes significant 79 | - **Long-running daemon processes** → use Supervisor or systemd instead 80 | 81 | --- 82 | 83 | ## Real-World Use Cases 84 | 85 | ### 1. Batch Data Import 86 | 87 | Import thousands of records by processing files in parallel: 88 | 89 | ```php 90 | $manager = new ProcessManager(executable: 'php', maxConcurrentProcesses: 5); 91 | 92 | foreach (glob('/data/imports/*.csv') as $file) { 93 | $manager->addScript('import-worker.php', arguments: [$file], maxExecutionTime: 300); 94 | } 95 | 96 | $results = $manager->run(); 97 | echo "Imported " . count(array_filter($results, fn($r) => $r->wasSuccessful)) . " files\n"; 98 | ``` 99 | 100 | ### 2. Multi-API Data Collection 101 | 102 | Fetch data from multiple APIs simultaneously: 103 | 104 | ```php 105 | $endpoints = ['users', 'orders', 'products', 'inventory', 'analytics']; 106 | 107 | $manager = new ProcessManager(executable: 'php', maxConcurrentProcesses: 3); 108 | 109 | foreach ($endpoints as $endpoint) { 110 | $manager->addScript('fetch-api.php', arguments: [$endpoint], maxExecutionTime: 60); 111 | } 112 | 113 | $results = $manager->run(); // All 5 endpoints fetched in ~2 batches instead of 5 sequential calls 114 | ``` 115 | 116 | ### 3. Image/Video Processing Pipeline 117 | 118 | Process media files in parallel using CLI tools: 119 | 120 | ```php 121 | $manager = new ProcessManager(executable: 'ffmpeg', maxConcurrentProcesses: 4); 122 | 123 | foreach ($videoFiles as $video) { 124 | $manager->addScript("-i {$video} -vf scale=1280:720 output/{$video}", maxExecutionTime: 600); 125 | } 126 | 127 | $manager->run(); 128 | ``` 129 | 130 | ### 4. Database Migration Runner 131 | 132 | Run independent migrations concurrently: 133 | 134 | ```php 135 | $manager = new ProcessManager(executable: 'php', maxConcurrentProcesses: 3); 136 | 137 | $manager->addScripts([ 138 | ['script' => 'migrate-users.php', 'maxExecutionTime' => 300], 139 | ['script' => 'migrate-orders.php', 'maxExecutionTime' => 600], 140 | ['script' => 'migrate-products.php', 'maxExecutionTime' => 300], 141 | ['script' => 'migrate-analytics.php', 'maxExecutionTime' => 900], 142 | ]); 143 | 144 | $results = $manager->run(); 145 | ``` 146 | 147 | ### 5. Parallel Test Execution 148 | 149 | Run test suites faster: 150 | 151 | ```php 152 | $manager = new ProcessManager(executable: 'php', maxConcurrentProcesses: 4); 153 | 154 | foreach (glob('tests/*Test.php') as $testFile) { 155 | $manager->addScript('vendor/bin/phpunit', arguments: [$testFile], maxExecutionTime: 120); 156 | } 157 | 158 | $results = $manager->run(); 159 | $failed = array_filter($results, fn($r) => !$r->wasSuccessful); 160 | 161 | exit(count($failed) > 0 ? 1 : 0); 162 | ``` 163 | 164 | ### 6. Multi-Server Deployment 165 | 166 | Deploy to multiple servers simultaneously: 167 | 168 | ```php 169 | $servers = ['web1.example.com', 'web2.example.com', 'web3.example.com']; 170 | 171 | $manager = new ProcessManager(executable: 'ssh', maxConcurrentProcesses: 10); 172 | 173 | foreach ($servers as $server) { 174 | $manager->addScript("{$server} 'cd /app && git pull && composer install'", maxExecutionTime: 120); 175 | } 176 | 177 | $results = $manager->run(); 178 | ``` 179 | 180 | --- 181 | 182 | ## Features 183 | 184 | - 🚀 **Concurrent Execution** - Run multiple processes in parallel 185 | - ⏱️ **Timeout Management** - Automatically kill processes that exceed time limits 186 | - 📊 **Result Tracking** - Get detailed results for each process (exit codes, output, timing) 187 | - 🎨 **Flexible Output** - Console, HTML, or custom output handlers 188 | - 🔧 **Fluent API** - Chain configuration methods for clean setup 189 | - 🏷️ **Fully Typed** - PHP 8.2+ with strict typing and readonly classes 190 | 191 | ## Requirements 192 | 193 | - PHP 8.2 or higher 194 | - `proc_open` function enabled 195 | 196 | ## Installation 197 | 198 | ### Via Composer 199 | 200 | ```bash 201 | composer require dalehurley/process-manager 202 | ``` 203 | 204 | ### Manual Installation 205 | 206 | Clone the repository and include the autoloader: 207 | 208 | ```bash 209 | git clone https://github.com/dalehurley/PHP-Process-Manager.git 210 | cd PHP-Process-Manager 211 | composer install 212 | ``` 213 | 214 | ## Quick Start 215 | 216 | ```php 217 | addScript('task1.php', maxExecutionTime: 60); 232 | $manager->addScript('task2.php', maxExecutionTime: 30); 233 | $manager->addScript('task3.php', maxExecutionTime: 120); 234 | 235 | // Execute and get results 236 | $results = $manager->run(); 237 | 238 | foreach ($results as $result) { 239 | echo "{$result->script}: " . ($result->wasSuccessful ? 'SUCCESS' : 'FAILED') . "\n"; 240 | } 241 | ``` 242 | 243 | ## Usage 244 | 245 | ### Basic Configuration 246 | 247 | ```php 248 | use DaleHurley\ProcessManager\ProcessManager; 249 | 250 | // Constructor parameters 251 | $manager = new ProcessManager( 252 | executable: 'php', // Command to execute 253 | workingDirectory: './scripts', // Directory containing scripts 254 | maxConcurrentProcesses: 5, // Max parallel processes 255 | sleepInterval: 1 // Seconds between status checks 256 | ); 257 | ``` 258 | 259 | ### Fluent API 260 | 261 | ```php 262 | $manager = new ProcessManager(); 263 | 264 | $manager 265 | ->setExecutable('python') 266 | ->setWorkingDirectory('/path/to/scripts') 267 | ->setMaxConcurrentProcesses(10) 268 | ->setSleepInterval(2) 269 | ->setOutputHandler(new ConsoleOutputHandler()); 270 | ``` 271 | 272 | ### Adding Scripts 273 | 274 | ```php 275 | // Add a single script with default timeout (300 seconds) 276 | $manager->addScript('worker.php'); 277 | 278 | // Add with custom timeout 279 | $manager->addScript('long-task.php', maxExecutionTime: 600); 280 | 281 | // Add with arguments and environment variables 282 | $manager->addScript( 283 | script: 'process-data.php', 284 | maxExecutionTime: 120, 285 | arguments: ['--batch', '100'], 286 | environment: ['DEBUG' => '1', 'LOG_LEVEL' => 'verbose'] 287 | ); 288 | 289 | // Add multiple scripts at once 290 | $manager->addScripts([ 291 | 'task1.php', 292 | 'task2.php', 293 | ['script' => 'task3.php', 'maxExecutionTime' => 60], 294 | ]); 295 | ``` 296 | 297 | ### Output Handlers 298 | 299 | The package includes several output handlers: 300 | 301 | ```php 302 | use DaleHurley\ProcessManager\Output\ConsoleOutputHandler; 303 | use DaleHurley\ProcessManager\Output\HtmlOutputHandler; 304 | use DaleHurley\ProcessManager\Output\NullOutputHandler; 305 | 306 | // Console output with colors (for CLI) 307 | $manager->setOutputHandler(new ConsoleOutputHandler(useColors: true)); 308 | 309 | // HTML output (for web) 310 | $manager->setOutputHandler(new HtmlOutputHandler(flush: true)); 311 | 312 | // No output (silent mode - default) 313 | $manager->setOutputHandler(new NullOutputHandler()); 314 | ``` 315 | 316 | ### Custom Output Handler 317 | 318 | Implement the `OutputHandlerInterface` for custom output: 319 | 320 | ```php 321 | use DaleHurley\ProcessManager\Output\OutputHandlerInterface; 322 | 323 | class LogOutputHandler implements OutputHandlerInterface 324 | { 325 | public function __construct(private Logger $logger) {} 326 | 327 | public function scriptAdded(string $script): void 328 | { 329 | $this->logger->info("Queued: {$script}"); 330 | } 331 | 332 | public function scriptCompleted(string $script): void 333 | { 334 | $this->logger->info("Completed: {$script}"); 335 | } 336 | 337 | public function scriptKilled(string $script): void 338 | { 339 | $this->logger->warning("Killed: {$script}"); 340 | } 341 | 342 | public function info(string $message): void 343 | { 344 | $this->logger->info($message); 345 | } 346 | 347 | public function error(string $message): void 348 | { 349 | $this->logger->error($message); 350 | } 351 | } 352 | ``` 353 | 354 | ### Working with Results 355 | 356 | ```php 357 | $results = $manager->run(); 358 | 359 | foreach ($results as $result) { 360 | // Access result properties 361 | echo "Script: {$result->script}\n"; 362 | echo "Exit Code: {$result->exitCode}\n"; 363 | echo "Duration: {$result->elapsedTime}s\n"; 364 | echo "Success: " . ($result->wasSuccessful ? 'Yes' : 'No') . "\n"; 365 | echo "Killed: " . ($result->wasKilled ? 'Yes' : 'No') . "\n"; 366 | 367 | if ($result->output) { 368 | echo "Output: {$result->output}\n"; 369 | } 370 | 371 | if ($result->hasErrors()) { 372 | echo "Errors: {$result->errorOutput}\n"; 373 | } 374 | 375 | // Convert to array 376 | $data = $result->toArray(); 377 | } 378 | 379 | // Analyze results 380 | $successful = array_filter($results, fn($r) => $r->wasSuccessful); 381 | $failed = array_filter($results, fn($r) => !$r->wasSuccessful); 382 | $killed = array_filter($results, fn($r) => $r->wasKilled); 383 | ``` 384 | 385 | ## API Reference 386 | 387 | ### ProcessManager 388 | 389 | | Method | Description | 390 | | --------------------------------------------------- | ---------------------------------- | 391 | | `setExecutable(string $executable)` | Set the command to execute | 392 | | `setWorkingDirectory(string $path)` | Set the working directory | 393 | | `setMaxConcurrentProcesses(int $count)` | Set max parallel processes | 394 | | `setSleepInterval(int $seconds)` | Set interval between status checks | 395 | | `setOutputHandler(OutputHandlerInterface $handler)` | Set the output handler | 396 | | `addScript(string $script, ...)` | Add a script to the queue | 397 | | `addScripts(array $scripts)` | Add multiple scripts | 398 | | `getQueueCount()` | Get number of queued scripts | 399 | | `getRunningCount()` | Get number of running processes | 400 | | `clearQueue()` | Clear the script queue | 401 | | `run()` | Execute all queued scripts | 402 | 403 | ### ProcessResult 404 | 405 | | Property | Type | Description | 406 | | --------------- | -------- | -------------------------- | 407 | | `script` | `string` | Script name | 408 | | `exitCode` | `int` | Process exit code | 409 | | `output` | `string` | stdout content | 410 | | `errorOutput` | `string` | stderr content | 411 | | `elapsedTime` | `int` | Execution time in seconds | 412 | | `wasKilled` | `bool` | Whether process was killed | 413 | | `wasSuccessful` | `bool` | Whether process succeeded | 414 | 415 | ## Upgrading from v1.x 416 | 417 | The 2.0 release is a complete rewrite with breaking changes: 418 | 419 | ```php 420 | // Old (v1.x) 421 | $manager = new Processmanager(); 422 | $manager->executable = "php"; 423 | $manager->root = ""; 424 | $manager->processes = 3; 425 | $manager->show_output = true; 426 | $manager->addScript("script.php", 300); 427 | $manager->exec(); 428 | 429 | // New (v2.x) 430 | use DaleHurley\ProcessManager\ProcessManager; 431 | use DaleHurley\ProcessManager\Output\ConsoleOutputHandler; 432 | 433 | $manager = new ProcessManager( 434 | executable: 'php', 435 | workingDirectory: '', 436 | maxConcurrentProcesses: 3, 437 | outputHandler: new ConsoleOutputHandler() 438 | ); 439 | $manager->addScript('script.php', maxExecutionTime: 300); 440 | $results = $manager->run(); 441 | ``` 442 | 443 | ### Key Changes 444 | 445 | - Namespace: `DaleHurley\ProcessManager` 446 | - Class renamed: `Processmanager` → `ProcessManager` 447 | - Method renamed: `exec()` → `run()` 448 | - Property renamed: `root` → `workingDirectory` 449 | - Property renamed: `processes` → `maxConcurrentProcesses` 450 | - Output handling now uses dedicated handler classes 451 | - Returns detailed `ProcessResult` objects instead of void 452 | 453 | ## Testing 454 | 455 | Run the test suite with PHPUnit: 456 | 457 | ```bash 458 | composer test 459 | ``` 460 | 461 | Run static analysis with PHPStan (level 8): 462 | 463 | ```bash 464 | composer analyse 465 | ``` 466 | 467 | --- 468 | 469 | ## Alternatives 470 | 471 | This package is intentionally simple and lightweight. Depending on your needs, consider these alternatives: 472 | 473 | ### For More Complex Process Management 474 | 475 | | Package | Description | Best For | 476 | | -------------------------------------------------------------------------- | --------------------------------------- | ----------------------------------------- | 477 | | [symfony/process](https://symfony.com/doc/current/components/process.html) | Full-featured process component | Single process with advanced I/O handling | 478 | | [spatie/async](https://github.com/spatie/async) | Asynchronous process handling with Pool | Similar use case with event-driven API | 479 | | [amphp/parallel](https://amphp.org/parallel) | True parallel execution with workers | High-performance async applications | 480 | 481 | ### For Queue-Based Processing 482 | 483 | | Package | Description | Best For | 484 | | ---------------------------------------------------------------------------------------------------- | ------------------------------------ | ----------------------------------------- | 485 | | [Laravel Queues](https://laravel.com/docs/queues) | Queue system with multiple backends | Laravel applications, distributed workers | 486 | | [Symfony Messenger](https://symfony.com/doc/current/messenger.html) | Message bus with queue transport | Symfony applications, event-driven | 487 | | [php-enqueue](https://php-enqueue.github.io/) | Framework-agnostic queue abstraction | Multi-backend queue support | 488 | | [Beanstalkd](https://beanstalkd.github.io/) + [Pheanstalk](https://github.com/pheanstalk/pheanstalk) | Lightweight job queue | Simple job queuing | 489 | 490 | ### For True Multi-Threading 491 | 492 | | Package | Description | Best For | 493 | | ----------------------------------------------------------- | ------------------------------------ | ------------------------------------------------ | 494 | | [parallel](https://www.php.net/manual/en/book.parallel.php) | PHP extension for parallel execution | Shared memory, true threading (requires ZTS PHP) | 495 | | [pthreads](https://github.com/krakjoe/pthreads) | Threading extension (PHP 7 only) | Legacy threading needs | 496 | 497 | ### When to Choose This Package 498 | 499 | Choose **PHP Process Manager** when you need: 500 | 501 | - ✅ Simple, zero-dependency process spawning 502 | - ✅ Quick setup without infrastructure (no Redis, no queue server) 503 | - ✅ Timeout management built-in 504 | - ✅ Result collection from all processes 505 | - ✅ CLI script orchestration 506 | - ✅ Lightweight alternative to full queue systems 507 | 508 | Choose **alternatives** when you need: 509 | 510 | - ❌ Persistent job storage and retry logic → use queues 511 | - ❌ Distributed processing across servers → use message queues 512 | - ❌ Shared memory between tasks → use parallel extension 513 | - ❌ Web-scale throughput → use dedicated worker systems 514 | 515 | --- 516 | 517 | ## License 518 | 519 | MIT License. See [LICENSE](LICENSE) for details. 520 | 521 | ## Credits 522 | 523 | - Original concept by Matou Havlena (havlena.net) 524 | - Modernized by Dale Hurley (dalehurley.com) 525 | --------------------------------------------------------------------------------