Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Ssh Laravel Package

spatie/ssh

spatie/ssh is a lightweight PHP package for running commands over an SSH connection. Execute commands synchronously or asynchronously, run multiple commands, choose a custom port, and inspect results via Symfony's Process.

View on GitHub
Deep Wiki
Context7

Getting Started

spatie/ssh is a thin, dependency-free wrapper around the local ssh and scp binaries. There is no service provider, no config file, no facade registration — it is a static factory that returns a configured Ssh object, and execute() hands you back a Symfony Process. That means the whole mental model is "I am building a shell command line and running it locally."

Minimum setup:

composer require spatie/ssh

Local prerequisites (these are runtime dependencies the package cannot check for you):

  • ssh (OpenSSH client) on the machine running PHP
  • scp if you plan to use upload() / download()
  • sshpass if you plan to use usePassword() — without it, auth fails and you get no useful error

Smoke test:

$process = Ssh::create('deploy', 'example.com')->execute('uptime');

$process->isSuccessful();     // bool
$process->getOutput();        // stdout
$process->getErrorOutput();   // stderr — check this, not just output

Where to look first:

  1. The README is genuinely complete for the public surface — every method is documented there.
  2. Then open Spatie\Ssh\Ssh itself. It is a few hundred lines and every method is a fluent setter that appends a flag. Reading it once removes all the guesswork.
  3. The single most useful debugging tool: $process->getCommandLine(). It prints the exact ssh invocation the package built, which tells you instantly whether a flag, quote, or port is wrong.

Best first use case: a one-off operations task in an Artisan command — cache clears, log rotation, a health check against a fleet of servers — where you'd otherwise be shelling out to ssh by hand or reaching for a full SSH2 extension.

Implementation Patterns

Config-driven connection factory. Because Ssh::create() is static and stateless, resist scattering Ssh::create('user', 'host') through the codebase. Resolve connections from config once and hand out configured instances:

class ServerFactory
{
    public function make(string $name): Ssh
    {
        $server = config("servers.{$name}");

        return Ssh::create($server['user'], $server['host'], $server['port'])
            ->usePrivateKey($server['key'])
            ->setTimeout($server['timeout'] ?? 30)
            ->configureProcess(fn (Process $p) => $p->setTimeout($server['process_timeout'] ?? 300));
    }
}

Use the array form as a single shell session. Passing an array joins the commands with && and runs them in one SSH connection, so state (cd, export, source venv/bin/activate) persists between entries:

Ssh::create('deploy', 'host')->execute([
    'cd /var/www/app',
    'git pull --ff-only',
    'composer install --no-dev --no-interaction',
    'php artisan migrate --force',
]);

This is the idiomatic deployment primitive. The tradeoff: && short-circuits, so if you want "run all regardless", loop over separate execute() calls and collect results yourself.

Fan-out with result collection. For multi-server operations, build a small result object rather than passing raw Process instances around:

$results = collect($servers)->mapWithKeys(function (string $host) {
    $process = $factory->make($host)->execute('systemctl is-active nginx');

    return [$host => [
        'ok'     => $process->isSuccessful(),
        'output' => trim($process->getOutput()),
        'error'  => trim($process->getErrorOutput()),
    ]];
});

Pair this with useMultiplexing('/home/deploy/.ssh/cm/%C', '10m') when hitting the same host repeatedly — it reuses one TCP connection and turns a per-command 300ms handshake into near-zero.

Streaming long output. onOutput() is the pattern for anything that runs longer than a few seconds; without it you get a silent black box until completion:

Ssh::create('user', 'host')
    ->onOutput(fn ($type, $line) => $this->output->write($line))
    ->execute('tail -n 100 /var/log/nginx/error.log');

The callback receives Process::OUT or Process::ERR, so you can route stderr to a different colour or buffer it for error reporting. This is how you make a Symfony Console command that feels alive rather than hung.

Async for genuinely parallel work. executeAsync() starts the process and returns immediately. The correct completion pattern is:

$process = Ssh::create('user', 'host')->executeAsync('long-running-job');
// ... start other work ...
$process->wait();          // or $process->wait($callback) for output
$process->isSuccessful();

Use it to run commands on N hosts concurrently in a queued job, then wait() on all of them. Do not use it at the end of a web request expecting the work to survive — when PHP exits, children are orphaned or killed.

File transfer as the backup/restore idiom. upload() and download() are scp under the hood, so the pattern is: execute('mysqldump ... > /tmp/dump.sql'), then download('/tmp/dump.sql', storage_path('backups/dump.sql')), then clean up remotely. Same for shipping a build artifact up.

Test seam. The package ships no fake, and Process is awkward to mock. Wrap it behind a one-method interface (execute(string|array $command): Process) implemented by a real "SSH adapter" in production and an in-memory fake in tests. This one small indirection is what keeps your deployment logic testable.

Gotchas and Tips

  • Host key checking is on by default (deliberate, since 1.1.1). Any ephemeral environment — CI containers, freshly provisioned VMs, Docker targets — will fail with Host key verification failed. disableStrictHostKeyChecking() is the one-liner, but it is a real security downgrade; the better fix is pre-seeding known_hosts via ssh-keyscan in your provisioning step, or scoping the relaxation to non-production hosts only.
  • Password auth fails silently without sshpass. The README calls this out explicitly. If usePassword() "works" locally and not in your container, install sshpass in the image before debugging anything else. Also be aware that password-based auth is the slowest, least auditable path — a deploy key via usePrivateKey() is the better default.
  • The local Process has its own 60-second default timeout, independent of whatever SSH-level timeout you configured. Long-running remote commands will throw ProcessTimedOutException seemingly out of nowhere. Always set configureProcess(fn (Process $p) => $p->setTimeout(null)) (or a generous value) on any command that could exceed a minute. Conversely, setTimeout() handles the SSH-side timeout — set both deliberately.
  • The bash -c wrapper can break on non-Linux targets. Commands are piped to bash on the remote host by default. Hosts without bash (Alpine minimal images, FreeBSD, fish/zsh setups) will behave unexpectedly; there is an option added in 1.8.3 to drop the wrapper. Verify against your actual remote shell rather than assuming.
  • Windows targets need onWindows(). Without it, cmd.exe always returns exit code 0, so isSuccessful() lies to you and error handling silently evaporates. This was only fixed in 1.13.2 — pin at ^1.13.2 if you touch Windows hosts.
  • Quoting and injection are entirely your problem. The command string is wrapped in single quotes for the remote shell, so an interpolated value containing a single quote breaks out. Never interpolate request data or filenames into the command string: escapeshellarg() every dynamic fragment, or better, pass the value through an environment variable or a file.
  • scp has different flag semantics than ssh. Ports use -P, not -p, and there has been a history of timeout flags leaking into scp and breaking transfers (fixed in 1.9.2). Don't assume a flag that works for execute() also works for upload() — check getCommandLine() when transfer errors look nonsensical.
  • Multiplexing needs an existing control directory with tight permissions. useMultiplexing('/home/deploy/.ssh/cm/%C', '10m') will fail confusingly if /home/deploy/.ssh/cm doesn't exist. Create it in provisioning with 0700 and mkdir -p.
  • Check getErrorOutput() even on success. Remote tools happily write warnings to stderr while exiting 0. Likewise, isSuccessful() alone is a weak signal for shell pipelines — false | true exits 0. If exit status matters, wrap remote commands with set -o pipefail or check output content.
  • No retries, no connection pooling by default. Every execute() is a fresh TCP + auth round trip. Network blips surface as ProcessFailedException / non-zero exit with ssh: connect to host ... port 22: Connection timed out on stderr. Wrap idempotent operations in a bounded retry with backoff; make non-idempotent ones explicitly single-shot.
  • IPv6 hosts need bracketing. Support was added in 1.8.2, but hosts containing colons must be written as [::1] in the parts of the command line that parse host:port, or the address gets mis-split.
  • The user is optional as of 1.10.1. Passing null lets ~/.ssh/config resolve the user — which is often what you want on a developer machine, but a silent source of "works on my machine" failures in CI where no SSH config exists. Be explicit in production config.
  • Extension points are limited, and that's the design. There is exactly one: configureProcess(). Anything else — logging, metrics, secrets retrieval — belongs in your adapter layer above the package, not inside it. Treat Ssh as an infrastructure detail with a narrow, replaceable surface.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
andydefer/laravel-notification
adachsoft/cli-process-tester
jhaskell13/laraflow
sebastienheyd/boilerplate-media-manager
ccast/tagixo-primix
pretendtrue/laravel-builder
sawmainek/laravel-scout-tntsearch-driver
tomshaw/laravel-calendar-table
dibakar/laravel-ownership
steadrun/laravel-monitor
labyrinthe/connect-laravel
elmogy/layan
drewdan/sentinel-client
dskripchenko/laravel-llm
fatryst/nuonuo
stevenay/myanfont
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc