DEV Community

Cover image for Two gotchas when your Laravel app shells out to a CLI
Nasrul Hazim
Nasrul Hazim

Posted on

Two gotchas when your Laravel app shells out to a CLI

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 bare PATH. Tools that read ~/.docker or ~/.kube silently fail. Inject the env explicitly.
  • Wrap the shell-out in a driver/contract so you can fake it in tests instead of running real docker in 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');
Enter fullscreen mode Exit fullscreen mode

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,
]);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode
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);
});
Enter fullscreen mode Exit fullscreen mode

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)