innmind/server-control
A lightweight, framework-agnostic PHP library for controlling servers and remote processes. It offers a clean API to define connections, execute commands, and manage start, stop, restart, and status operations, making server automation easier to integrate, extend, and test.
innmind/server-control is not a standalone process runner you reach for directly — it is the process/volume abstraction layer behind innmind/operating-system. That single fact shapes everything about how you adopt it.
Install
composer require innmind/server-control
Where to look first (in this order)
Innmind\Server\Control\Server — the root interface. It is deliberately small: process control plus mounted-volume/disk inspection. Read this before anything else; everything else is an implementation detail hanging off it.Innmind\Server\Control\Server\Command — the immutable, fluent command builder (foreground(), background(), withArgument(), withOption(), withWorkingDirectory(), withEnvironment()).Innmind\Server\Control\Server\Process — what execute() hands back: wait(), output(), exitCode(), pid().Innmind\Server\Control\Server\Signal — for kill() / terminate() / interrupt().Getting a Server instance. In practice you almost never new one up. You get it from the OS abstraction:
use Innmind\OperatingSystem\Factory;
use Innmind\Server\Control\Server\Command;
$os = Factory::build();
$server = $os->control();
$process = $server->processes()->execute(
Command::foreground('ls')->withArgument('-l')
);
$process->wait();
echo $process->output()->toString();
If you are not already using innmind/operating-system, use the concrete Unix implementation shipped with this package. Check the src/ tree for the Unix namespace before assuming a class name — the constructor signatures change between majors.
First real use case: replace exec() / shell_exec() / backticks in a deploy script or Artisan command with Command::foreground(...)->withArgument(...), execute, wait(), then inspect exitCode() and output(). That alone buys you exit-code handling and separated stdout/stderr, which shell_exec() does not give you.
Prerequisites you should expect: the package is built on innmind/immutable (Str, Sequence, Set, Maybe). If your team has not used Maybe/Either before, budget an hour for that — it is the main friction point, not the process API.
Wrap it behind your own contract. Because Server is an interface, the highest-value pattern is a thin application-level wrapper:
interface Shell
{
public function run(Command $command): Result;
}
You bind the real implementation ($os->control()->processes()) in a Laravel service provider, and a fake in tests. This is also your insurance against the package's API churn (see Gotchas). Never inject Innmind\Server\Control\Server directly into twenty classes.
Centralise command construction. Treat Command objects like query objects: one factory per domain action.
final class DumpDatabase
{
public static function command(string $db, string $path): Command
{
return Command::foreground('mysqldump')
->withArgument($db)
->withOption('result-file', $path)
->withWorkingDirectory('/var/www/app');
}
}
This is where you get all your escaping discipline in one place, and where you can assert on the command in tests without spawning anything.
Background work + PID tracking. For long tasks (backups, rsync, queue warmers) use Command::background(), persist $process->pid(), and later re-acquire with $server->processes()->get($pid) — which returns a Maybe, so you handle "already exited" explicitly instead of racing on posix_kill. This is the pattern that makes the package meaningfully better than exec('... > /dev/null 2>&1 &').
Polling supervision. There is no built-in timeout. The idiomatic approach is a loop: get($pid) until it returns nothing(), sleeping via $os->clock()->sleep(). Use this for queue jobs that shell out, so a hung rsync cannot pin a worker forever. Combine with kill($pid, Signal::terminate()), then escalate to Signal::kill() after a grace period.
Streaming output into Artisan. For interactive commands, read the output sequence incrementally and forward it to $this->output->write() so the operator sees progress. Waiting for wait() and only then dumping output looks frozen on a 10-minute mysqldump.
Preflight with volumes. Before running a backup or an npm ci, check free space on the target mount rather than discovering it mid-write. $server->volumes() gives you mount points and total/free size (Maybe-wrapped, because not every filesystem reports it) — a clean way to fail fast with a real error message.
Laravel integration points. Bind Server as a singleton in a service provider. Use it in Artisan commands (deploy, backup, health), in queued jobs for anything over a second, and in a HealthCheck service. Do not use it in HTTP request handlers for anything but trivial, sub-100ms commands — FPM request lifetimes and long-lived child processes do not mix.
Test seam. Because the entry point is an interface, the clean fake is an in-memory Processes returning canned Process objects with pre-set output and exit codes. Assert on Command values (arguments, options, cwd) rather than on side effects.
Immutability bites everyone. Command::foreground('ls')->withArgument('-l') returns a new command. $command->withOption(...) without assignment silently does nothing, and your command runs without the flag. The same applies throughout innmind/immutable. This is the single most common bug with this package.
execute() does not block. It returns a live process. You must wait() or drain output. Code that calls execute() and immediately reads exitCode() will get a meaningless value, or throw. Also check the type — wait() returns an exit-code value object, not an int, in most versions.
Output is finite and can be drained once. Depending on the version, Process::output() gives you buffered chunks rather than a live stream. Reading it after wait() is the safe default; reading it mid-flight is what you need for progress output, but you must actively consume it. If the child writes more than the pipe buffer and nobody reads, you deadlock on wait(). For chatty commands, drain.
stdout and stderr are separated — use that. Do not concatenate them blindly. A successful-looking command that wrote to stderr is a very different thing from a clean one. In Laravel, log them on separate channels; it makes debugging deploy scripts dramatically faster.
Environment replacement, not merging. withEnvironment() on the immutable command typically sets the environment rather than layering onto the inherited one. If you set one variable and suddenly PATH is gone, that is why. In FPM/queue workers, pass an explicit full environment (PATH, HOME, application secrets) rather than assuming inheritance.
Working directory is not implicit. Resolve it to an absolute path before building the command. Under a queue worker the cwd is the project root; under FPM it is /. Relative paths silently diverge between the two.
Signals do not kill grandchildren. signal/kill targets a PID. If your command is sh -c "a && b", killing the shell may orphan the real process. Prefer direct binaries over shell one-liners, or run under a supervisor (systemd, supervisord) and control it via commands rather than PIDs.
No built-in timeout, no retry, no shell quoting. Build all three yourself, at the wrapper layer. Treat every string argument as untrusted input and pass it through withArgument() — never concatenate into a command string, because whatever shell layer exists will interpolate it.
Unix-only, effectively. Verify before promising Windows support. posix_*-adjacent behaviour and volume inspection are Unix-shaped. On constrained hosts, proc_open may be in disable_functions, open_basedir may block the binary, and the PHP user may lack permission to signal the process — all of which surface as generic failures, so log the command and the raw stderr.
Debug by logging the command as a string first. Before executing, stringify the Command and log it. Half of all "the package is broken" reports are a missing argument from a dropped fluent return value.
Version churn and ecosystem risk. This package has zero dependents and effectively one maintainer. Innmind majors move fast and break signatures, and releases are frequent. Pin exact versions in composer.json, keep the wrapper boundary thin, and be prepared to swap in symfony/process — which Laravel already ships — if the API or maintenance cadence becomes a problem. The honest calculus: this package's value is the typed interface and the free integration with innmind/operating-system; if you are not using the Innmind ecosystem elsewhere, symfony/process is the lower-risk default and you should say so out loud in your design review.
Extension points worth knowing. Implementing Processes (in-memory fake, or an SSH transport that proxied commands to a remote host) and implementing Server are the two seams the design intends. The second is how you would swap the whole abstraction for a container-based runner later without touching call sites.
How can I help you explore Laravel packages today?