TL;DR
- If a web request shells out to a slow CLI (
docker,kubectl,rsync…), move it to a queued job — PHP-FPM/nginx will kill it at ~30s anyway. - Processes spawned from an HTTP worker inherit almost no environment: no
HOME, a barePATH. Tools that read~/.dockeror~/.kubesilently fail. Inject the env explicitly. - Wrap the shell-out in a driver/contract so you can fake it in tests instead of running real
dockerin CI.
The setup
I have an app that provisions infrastructure by calling command-line tools — docker here, kubectl there. Classic "orchestrate the CLI from PHP" situation. It worked perfectly from php artisan tinker on my machine and fell apart the moment it ran from a browser request. Two separate reasons, both worth knowing.
Gotcha 1: the web request is the wrong place
Provisioning takes 40–90 seconds. A web request does not have that long. nginx/PHP-FPM cut you off around 30s (max_execution_time, fastcgi_read_timeout), the browser sees a 504, and — worse — you don't actually know if the work finished or died halfway.
The fix is boring and correct: the controller validates, dispatches a job, and returns immediately. The long-running shell-out happens on the queue where it can take minutes and retry cleanly.
// Controller/Livewire action — return fast
ProvisionDeploymentJob::dispatch($deployment);
return back()->with('status', 'Provisioning started');
The job owns the slow part, reports progress, and is the only place a real CLI ever runs.
Gotcha 2: HTTP workers have no HOME
This one cost me an afternoon. The queued job still failed — but only under the web-triggered worker, never from tinker. The error was docker and kubectl behaving as if they had no config: no registry auth, no cluster context.
The reason: a process spawned from an HTTP/FPM context inherits a stripped environment. HOME is often unset, and PATH is minimal. Both docker and kubectl resolve their config relative to HOME (~/.docker/config.json, ~/.kube/config). No HOME → they look in the wrong place → they act unauthenticated.
Symfony Process does not inherit your shell's env for you. So inject what the child needs:
use Symfony\Component\Process\Process;
$process = new Process($command, $workingDir, [
'HOME' => $this->home, // so docker/kubectl find their config
'PATH' => $this->path, // so the binary is resolvable at all
'KUBECONFIG' => $this->kubeconfig,
]);
The variables that actually matter when spawning these CLIs:
| Env var | Why it matters | Symptom if missing |
|---|---|---|
HOME |
Root for ~/.docker, ~/.kube
|
Tool runs unauthenticated / no context |
PATH |
Locate the binary | "command not found" |
KUBECONFIG |
Explicit kube context | Falls back to default/none |
Make it testable
Running real docker in CI is a non-starter. Put the shell-out behind a contract and resolve a fake in tests — the same driver-based pattern you'd use for any swappable backend.
interface WorkloadRuntimeContract
{
public function start(Deployment $deployment): void;
}
it('provisions without touching a real CLI', function () {
$this->app->bind(WorkloadRuntimeContract::class, FakeWorkloadRuntime::class);
ProvisionDeploymentJob::dispatch($deployment);
expect(app(WorkloadRuntimeContract::class)->started)
->toContain($deployment->id);
});
Takeaway
Two rules I'd tape to the wall: long CLI work belongs on the queue, not the request; and a process you spawn starts with an empty environment unless you fill it. The HOME-is-unset one is nasty because it only shows up in the HTTP path — the exact place that's hardest to debug. Give your child processes an explicit env and a fake for tests, and the whole thing stops being spooky.
Top comments (0)