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

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

The Problem With Laravel Envoy

What Scotty Does Differently

Installing Scotty

Migrating From Envoy

Zero-Downtime Deployments

So Is It Worth Switching? 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. 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. 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: 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 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. Install it as a global Composer package: Make sure Composer's global bin directory is in your $PATH. If you're not sure where it is: Once installed, verify it works: To create a new Scotty file in your project, run: 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. 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: The actual shell commands inside tasks don't change at all. You're just rewriting the wrappers. 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: 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: Or deploy a specific branch: 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. 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. 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. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

#!/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 -weight: 500;">git pull origin $BRANCH } # @task on:remote runComposer() { cd $APP_DIR composer -weight: 500;">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 } #!/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 -weight: 500;">git pull origin $BRANCH } # @task on:remote runComposer() { cd $APP_DIR composer -weight: 500;">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 } #!/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 -weight: 500;">git pull origin $BRANCH } # @task on:remote runComposer() { cd $APP_DIR composer -weight: 500;">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 } scotty run deploy --pretend scotty run deploy --pretend scotty run deploy --pretend composer global require spatie/scotty composer global require spatie/scotty composer global require spatie/scotty composer global config bin-dir --absolute composer global config bin-dir --absolute composer global config bin-dir --absolute scotty list scotty list scotty list scotty init scotty init scotty init /var/www/my-app/ ├── current -> /var/www/my-app/releases/20260406-140000 ├── persistent/ │ └── storage/ ├── releases/ │ ├── 20260406-130000/ │ └── 20260406-140000/ └── .env /var/www/my-app/ ├── current -> /var/www/my-app/releases/20260406-140000 ├── persistent/ │ └── storage/ ├── releases/ │ ├── 20260406-130000/ │ └── 20260406-140000/ └── .env /var/www/my-app/ ├── current -> /var/www/my-app/releases/20260406-140000 ├── persistent/ │ └── storage/ ├── releases/ │ ├── 20260406-130000/ │ └── 20260406-140000/ └── .env #!/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() { -weight: 500;">git checkout $BRANCH -weight: 500;">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 -weight: 500;">git clone --depth 1 --branch $BRANCH -weight: 500;">git@github.com:$REPOSITORY $NEW_RELEASE_NAME } # @task on:remote runComposer() { cd $NEW_RELEASE_DIR ln -nfs $BASE_DIR/.env .env composer -weight: 500;">install --prefer-dist --no-dev -o } # @task on:remote buildAssets() { cd $NEW_RELEASE_DIR -weight: 500;">npm ci -weight: 500;">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 -weight: 600;">sudo -weight: 500;">service php8.4-fpm -weight: 500;">restart } # @task on:remote cleanOldReleases() { cd $RELEASES_DIR ls -dt $RELEASES_DIR/* | tail -n +4 | xargs rm -rf } #!/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() { -weight: 500;">git checkout $BRANCH -weight: 500;">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 -weight: 500;">git clone --depth 1 --branch $BRANCH -weight: 500;">git@github.com:$REPOSITORY $NEW_RELEASE_NAME } # @task on:remote runComposer() { cd $NEW_RELEASE_DIR ln -nfs $BASE_DIR/.env .env composer -weight: 500;">install --prefer-dist --no-dev -o } # @task on:remote buildAssets() { cd $NEW_RELEASE_DIR -weight: 500;">npm ci -weight: 500;">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 -weight: 600;">sudo -weight: 500;">service php8.4-fpm -weight: 500;">restart } # @task on:remote cleanOldReleases() { cd $RELEASES_DIR ls -dt $RELEASES_DIR/* | tail -n +4 | xargs rm -rf } #!/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() { -weight: 500;">git checkout $BRANCH -weight: 500;">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 -weight: 500;">git clone --depth 1 --branch $BRANCH -weight: 500;">git@github.com:$REPOSITORY $NEW_RELEASE_NAME } # @task on:remote runComposer() { cd $NEW_RELEASE_DIR ln -nfs $BASE_DIR/.env .env composer -weight: 500;">install --prefer-dist --no-dev -o } # @task on:remote buildAssets() { cd $NEW_RELEASE_DIR -weight: 500;">npm ci -weight: 500;">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 -weight: 600;">sudo -weight: 500;">service php8.4-fpm -weight: 500;">restart } # @task on:remote cleanOldReleases() { cd $RELEASES_DIR ls -dt $RELEASES_DIR/* | tail -n +4 | xargs rm -rf } scotty run deploy scotty run deploy scotty run deploy scotty run deploy --branch=develop scotty run deploy --branch=develop scotty run deploy --branch=develop