├── 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 | [](https://packagist.org/packages/dalehurley/process-manager)
4 | [](https://packagist.org/packages/dalehurley/process-manager)
5 | [](https://packagist.org/packages/dalehurley/process-manager)
6 | [](LICENSE)
7 | [](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 |
--------------------------------------------------------------------------------