Supercharging SSH with SSH Runner for Laravel
SSH is the backbone of most server automation, and tools like Laravel Forge, Coolify, and Serversinc all rely on it in roughly the same way: SSH into a server, run a command, or execute a bash script that handles a sequence of steps. For simple automation that works perfectly well, but the trouble starts when workflows grow beyond a handful of commands.
As soon as you move into deployments, provisioning, or anything involving multiple stages, logic gets pushed into bash scripts executed over SSH. Over time, that logic drifts away from your Laravel application and becomes something you maintain separately, as a loosely connected collection of shell scripts that lives alongside your codebase rather than within it. It still works, but it stops feeling cohesive, and it becomes increasingly difficult to reason about what's actually happening on your servers.
What I ended up building
SSH Runner is my attempt to bring that workflow back into Laravel. Instead of treating SSH as a way to execute external scripts, I wanted server automation to exist as application logic, written in PHP, with structure and visibility. It's built on top of Spatie's SSH package, which handles the actual SSH execution layer. SSH Runner sits above that and adds structure around how commands are composed, executed, and handled.
The goal is simple: stop thinking of server automation as shell scripts, and start treating it as part of your application.
Actions, Scripts, and Script Steps
Actions are the smallest unit. They're single, reusable operations that run on a server and return a result, telling you whether the command succeeded and what it produced.
class CheckDiskSpace extends BaseSshAction
{
public function handle(SshServer $server, Ssh $ssh): ActionResult
{
return $this->run($ssh, ['df -h']);
}
}
Scripts are where you compose larger workflows that can't be contained in a single Action. For example; A script for deploying a container might pull an image, set environment variables and run the container.
Script Steps are where the control lives. Each step wraps a command and can optionally declare a rollback, giving you a clean way to undo partial work if something fails further down the script.
new ScriptStep(
name: 'Pull latest image',
command: 'docker pull myapp:latest',
rollback: 'docker image rm myapp:latest',
),
Each step is independent and reusable across different scripts, so common operations don't need to be rewritten every time.
When things go wrong
This was the part that pushed me to build this properly. When I was working on server automation in Serversinc, the hardest part wasn't running commands; it was figuring out what actually happened when something failed in the middle of a workflow. Piecing together what went wrong from raw SSH output across scattered scripts is tedious and error-prone, and it gets worse the more steps are involved.
So SSH Runner focuses heavily on structured results. When a script fails, you know exactly which step it failed on and why, rather than having to dig through output manually. On top of that, each script step can declare a rollback command. If a critical step fails, those rollback commands run automatically in reverse, so a half-provisioned server cleans itself up rather than sitting in a broken state waiting for you to sort it out manually:
new ScriptStep(
name: 'Create application directory',
command: "mkdir -p {$this->path}",
rollback: "rm -rf {$this->path}",
),
new ScriptStep(
name: 'Create database',
command: "mysql -e \"CREATE DATABASE IF NOT EXISTS {$this->dbName};\"",
rollback: "mysql -e \"DROP DATABASE IF EXISTS {$this->dbName};\"",
),
new ScriptStep(
name: 'Create database user',
command: "mysql -e \"CREATE USER IF NOT EXISTS '{$this->dbUser}'@'localhost' IDENTIFIED BY '{$this->dbPassword}'; GRANT ALL PRIVILEGES ON {$this->dbName}.* TO '{$this->dbUser}'@'localhost'; FLUSH PRIVILEGES;\"",
rollback: "mysql -e \"DROP USER IF EXISTS '{$this->dbUser}'@'localhost';\"",
),
new ScriptStep(
name: 'Set permissions',
command: "chown -R www-data:www-data {$this->path}",
critical: false, // Won't halt the script if this fails
),
Because all of this is structured application code, scripts can be triggered from anywhere: Laravel jobs, cron, API endpoints, whatever fits your workflow.
How I'm using it
The need for this package came directly from building Serversinc. The whole platform is built on running bash scripts over SSH for server actions, and over time it became difficult to understand what was actually happening during execution. When something failed, I was left digging through SSH output and loosely connected scripts to reconstruct what went wrong and what state the server was in.
Since moving this logic into SSH Runner, that experience has changed considerably. Scripts feel like a first-class part of the Laravel application rather than something external to it. The execution flow is easy to follow, failures surface clearly, and it's much easier to test and iterate on server workflows without worrying about breaking side effects in unrelated scripts.
Wrapping up
SSH is not going anywhere; it's still the right tool for executing commands on remote servers. But once you start building real systems on top of it, raw bash scripts over SSH start to feel limiting. Not because they don't work, but because they don't compose well as application-level logic and they make failures hard to handle gracefully.
SSH Runner is my way of addressing that inside Laravel. It takes the workflows I was already building and turns them into structured application code with clear execution flow, reliable results, and proper control over failures. If you're doing any meaningful server automation in a Laravel application, it's worth checking out: github.com/serversinc/ssh-runner.