DEV Community

Cover image for Scotty vs Laravel Envoy: Spatie's New Deploy Tool Is Worth the Switch
Hafiz
Hafiz

Posted on • Originally published at hafiz.dev

Scotty vs Laravel Envoy: Spatie's New Deploy Tool Is Worth the Switch

Originally published at hafiz.dev


Spatie released Scotty on March 30th. It's a new SSH task runner that does what Laravel Envoy does: run deploy scripts on remote servers. But it uses plain bash syntax instead of Blade templates, and gives you significantly better terminal output while tasks run.

Freek Van der Herten wrote about it on his blog: "Even though services like Laravel Cloud make it possible to never think about servers again, I still prefer deploying to my own servers for some projects." That's exactly the scenario Scotty targets. If you're on a DigitalOcean droplet, a Hetzner box, or anything you manage yourself, and you're either still SSH-ing in manually or running Envoy, Scotty is worth a look.

Let's break down what it actually does differently, whether it's a meaningful upgrade, and how to migrate or set it up from scratch.

The Problem With Laravel Envoy

Envoy works. I'm not going to pretend it's broken. But there are two friction points that come up every time you actually use it.

The first is the Blade file format. Your deploy script is an Envoy.blade.php file full of @task, @servers, @story directives and {{ $variable }} syntax. It looks like PHP, but it's not quite PHP. Your editor treats it differently depending on how your Blade support is configured. Shell linting won't touch it. Autocompletion for bash commands doesn't work inside the Blade blocks. It's a hybrid format that's slightly awkward for what is fundamentally a shell scripting task.

The second is the output. When Envoy runs, you see the commands executing one after another in a plain stream. There's no step counter, no elapsed time per task, no summary at the end. When something takes 40 seconds you're just watching text scroll by hoping nothing's wrong.

Scotty addresses both directly.

What Scotty Does Differently

Plain bash with annotation comments. Your script is a Scotty.sh file with a #!/usr/bin/env scotty shebang. Tasks are regular bash functions. Server targets and macros are annotation comments. It looks like this:

#!/usr/bin/env scotty

# @servers remote=deployer@your-server.com
# @macro deploy pullCode runComposer runMigrations clearCaches restartWorkers

APP_DIR="/var/www/my-app"
BRANCH="${BRANCH:-main}"

# @task on:remote confirm="Deploy to production?"
pullCode() {
    cd $APP_DIR
    git pull origin $BRANCH
}

# @task on:remote
runComposer() {
    cd $APP_DIR
    composer install --no-interaction --prefer-dist --optimize-autoloader --no-dev
}

# @task on:remote
runMigrations() {
    cd $APP_DIR
    php artisan migrate --force
}

# @task on:remote
clearCaches() {
    cd $APP_DIR
    php artisan config:cache
    php artisan route:cache
    php artisan view:cache
    php artisan event:cache
}

# @task on:remote
restartWorkers() {
    cd $APP_DIR
    php artisan horizon:terminate
}
Enter fullscreen mode Exit fullscreen mode

That's a complete deploy script. Notice that BRANCH="${BRANCH:-main}" is just bash. It defaults to main and accepts an override from the command line. No Blade interpolation needed. Your editor highlights it correctly. shellcheck can lint it. Bash autocomplete works inside the functions.

Live output with a summary table. While tasks run, Scotty shows each one with its name, a step counter, elapsed time, and the current command executing. When everything finishes, you get a summary table showing how long each step took. It's a small thing but it makes a real difference when a deploy takes two minutes and you need to know if the three-second Composer install is suspicious.

Pause and resume. If you need to interrupt a deploy mid-flight, press p and Scotty waits for the current task to finish, then pauses. Hit Enter to resume. This matters more than it sounds when you're deploying a hot fix at 11pm and something looks off.

The scotty doctor command. Run scotty doctor before your first deploy and it validates your Scotty.sh file, tests SSH connectivity to each server, and checks that PHP, Composer, and Git are installed on the remote machine. A pre-flight check that catches most setup issues before a deploy even starts.

--pretend mode. Before running a deploy on a new server for the first time, add the --pretend flag:

scotty run deploy --pretend
Enter fullscreen mode Exit fullscreen mode

Scotty prints every SSH command it would execute without actually connecting to anything. scotty doctor checks your setup. --pretend checks your script logic. Run both before you touch production for the first time.

Installing Scotty

Install it as a global Composer package:

composer global require spatie/scotty
Enter fullscreen mode Exit fullscreen mode

Make sure Composer's global bin directory is in your $PATH. If you're not sure where it is:

composer global config bin-dir --absolute
Enter fullscreen mode Exit fullscreen mode

Once installed, verify it works:

scotty list
Enter fullscreen mode Exit fullscreen mode

To create a new Scotty file in your project, run:

scotty init
Enter fullscreen mode Exit fullscreen mode

It asks for your server SSH connection string and generates a starter Scotty.sh file. Or just create the file manually. The format is simple enough that you don't really need a generator.

Migrating From Envoy

If you already have an Envoy.blade.php, you don't have to rewrite it immediately. Scotty reads Envoy files out of the box. Just run scotty run deploy against your existing Envoy file and it works.

When you're ready to migrate to the native format, the mental model is clear:

Envoy Scotty.sh
@servers(['web' => 'user@host']) # @servers remote=user@host
@story('deploy') ... @endstory # @macro deploy task1 task2
@task('pullCode', ['on' => 'web']) # @task on:remote followed by pullCode() { }
{{ $branch }} $BRANCH (plain bash variable)
@setup $branch = 'main'; @endsetup BRANCH="${BRANCH:-main}" at the top of the file

The actual shell commands inside tasks don't change at all. You're just rewriting the wrappers.

Zero-Downtime Deployments

This is where Scotty shines for production apps. The Scotty docs include a complete zero-downtime deploy script, and it's the same pattern Spatie uses for all their own applications.

The idea: instead of updating files in place (which means there's always a window where your code is half-updated), you clone each release into a new timestamped directory and flip a symlink when everything's ready. Here's what the directory structure looks like on the server:

/var/www/my-app/
├── current -> /var/www/my-app/releases/20260406-140000
├── persistent/
│   └── storage/
├── releases/
│   ├── 20260406-130000/
│   └── 20260406-140000/
└── .env
Enter fullscreen mode Exit fullscreen mode

Your Nginx document root points to /var/www/my-app/current/public. The current symlink gets updated atomically at the end of a successful deploy. If Composer fails or a migration breaks, current still points to the last working release and your users see nothing wrong.

Here's the complete zero-downtime script:

#!/usr/bin/env scotty

# @servers local=127.0.0.1 remote=deployer@your-server.com
# @macro deploy startDeployment cloneRepository runComposer buildAssets updateSymlinks migrateDatabase blessNewRelease cleanOldReleases

BASE_DIR="/var/www/my-app"
RELEASES_DIR="$BASE_DIR/releases"
PERSISTENT_DIR="$BASE_DIR/persistent"
CURRENT_DIR="$BASE_DIR/current"
NEW_RELEASE_NAME=$(date +%Y%m%d-%H%M%S)
NEW_RELEASE_DIR="$RELEASES_DIR/$NEW_RELEASE_NAME"
REPOSITORY="your-org/your-repo"
BRANCH="${BRANCH:-main}"

# @task on:local
startDeployment() {
    git checkout $BRANCH
    git pull origin $BRANCH
}

# @task on:remote
cloneRepository() {
    [ -d $RELEASES_DIR ] || mkdir -p $RELEASES_DIR
    [ -d $PERSISTENT_DIR ] || mkdir -p $PERSISTENT_DIR
    [ -d $PERSISTENT_DIR/storage ] || mkdir -p $PERSISTENT_DIR/storage
    cd $RELEASES_DIR
    git clone --depth 1 --branch $BRANCH git@github.com:$REPOSITORY $NEW_RELEASE_NAME
}

# @task on:remote
runComposer() {
    cd $NEW_RELEASE_DIR
    ln -nfs $BASE_DIR/.env .env
    composer install --prefer-dist --no-dev -o
}

# @task on:remote
buildAssets() {
    cd $NEW_RELEASE_DIR
    npm ci
    npm run build
    rm -rf node_modules
}

# @task on:remote
updateSymlinks() {
    rm -rf $NEW_RELEASE_DIR/storage
    cd $NEW_RELEASE_DIR
    ln -nfs $PERSISTENT_DIR/storage storage
}

# @task on:remote
migrateDatabase() {
    cd $NEW_RELEASE_DIR
    php artisan migrate --force
}

# @task on:remote
blessNewRelease() {
    ln -nfs $NEW_RELEASE_DIR $CURRENT_DIR
    cd $NEW_RELEASE_DIR
    php artisan config:cache
    php artisan route:cache
    php artisan view:cache
    php artisan event:cache
    php artisan cache:clear
    php artisan horizon:terminate
    sudo service php8.4-fpm restart
}

# @task on:remote
cleanOldReleases() {
    cd $RELEASES_DIR
    ls -dt $RELEASES_DIR/* | tail -n +4 | xargs rm -rf
}
Enter fullscreen mode Exit fullscreen mode

Run with:

scotty run deploy
Enter fullscreen mode Exit fullscreen mode

Or deploy a specific branch:

scotty run deploy --branch=develop
Enter fullscreen mode Exit fullscreen mode

A few things worth noting about this script. The startDeployment task runs locally: it checks out and pulls the branch on your machine first, so you catch any git conflicts before touching the server. The blessNewRelease task is where the symlink actually flips, so everything before that step is safe to fail. And cleanOldReleases keeps the three most recent releases on disk in case you ever need to inspect one.

If you're running queue workers with Horizon, php artisan horizon:terminate tells Supervisor to restart it with the new code once the current jobs finish. If you have a Laravel queue setup, this is the step that picks up your latest job definitions.

So Is It Worth Switching?

If you're starting a new project: yes, use Scotty from the beginning. The bash format is strictly better than Blade for shell scripting, and there's no migration cost.

If you're on Envoy and it's working: the migration is low-effort since Scotty reads your existing file as-is. The question is whether the output improvements and scotty doctor are worth 20 minutes of your time. For most projects, they are.

If you're on Laravel Forge's built-in deployment: Scotty isn't for you. Forge handles this well and gives you a UI for it. Scotty is for developers who prefer terminal-native control and version-controlled deploy scripts that live inside the repo.

If you're on Laravel Cloud: also not for you. The whole point of Cloud is that you don't manage servers. Scotty is specifically for self-hosted apps where you control the environment, whether that's a plain VPS or a Dockerized Laravel setup.

The honest verdict: Scotty is a clean, well-considered tool. It doesn't reinvent deployment, it just makes the script format sane and the output readable. For anyone self-hosting Laravel apps and already using Envoy, it's the obvious upgrade. For anyone who's never set up deploy automation at all, the docs give you a complete production-ready script to start from.

FAQ

Does Scotty work with multiple servers?

Yes. You can define multiple servers in the # @servers line and specify on:web, on:workers, etc. in individual tasks. You can also run tasks on multiple servers in parallel by adding the parallel option.

What's the difference between a task and a macro?

A task is a single function that runs shell commands on a target, either local or remote. A macro is a named sequence of tasks. It's what you actually run with scotty run deploy. Think of macros as your deploy pipeline definition.

Can I run Scotty in CI/CD?

Yes. Since it's a global Composer package, you install it in your CI environment the same way you would locally. It works anywhere you have SSH access to your server.

What happens if a task fails mid-deploy?

Scotty stops immediately at the failing task and shows you the error output. If you're using the zero-downtime script, the current symlink hasn't been updated yet, so your live application is untouched.

Do I need to commit the Scotty.sh file to my repo?

Yes, that's the recommended approach. The script lives in version control alongside your code, so your whole team has access to the same deploy process and changes to it go through normal code review.


Scotty's documentation is at spatie.be/docs/scotty and the source is on GitHub. If you're building out your server setup and want to harden it before adding deploy automation, the VPS hardening guide covers SSH keys, Cloudflare, and Tailscale on a fresh DigitalOcean droplet.

If automated deployments aren't on your radar yet because you're still in the build phase, reach out. Getting the deploy pipeline right early saves a lot of pain later.

Top comments (0)