Laravel Zero Downtime Deployment: CI Tests, Rollbacks, and Forge vs Envoyer Pricing - NextGenBeing
Back to discoveries

Laravel Zero Downtime Deployment: CI Tests, Rollbacks, and Forge vs Envoyer Pricing

Laravel zero downtime deployment with GitHub Actions tests, a symlink deploy script tested on real Laravel, /up health gate, rollbacks, and Forge vs Envoyer pricing.

Web Development 8 min read
Bekzod Erkinov

Bekzod Erkinov

Oct 22, 2026 • 0 views
Size:
Height:
8 min read 3,027 words Focus mode: Eye care:

Listen to Article

Loading...
0:00 / 0:00
0:00 0:00
Low High
0% 100%
⏸ Paused ▶️ Now playing... Ready to play ✓ Finished
Table of contents · 7 sections

Laravel Zero Downtime Deployment: CI Tests, Rollbacks, and Forge vs Envoyer Pricing

Most Laravel deploy pipelines fail in the same place: tests run on push, but the deploy itself is git pull && php artisan migrate on a live server, and "rollback" means panic. This post builds the pipeline in three parts, with a deploy script I ran against a real Laravel app, and compares Forge, Envoyer, Deployer and plain GitHub Actions on price and on what each vendor documents.

Affiliate disclosure: nextgenbeing.com earns commissions on some tools reviewed elsewhere on the site. This post contains no affiliate links; every link below goes to the official site or docs.

Forge vs Envoyer vs Deployer: quick verdict

If you... Pick Why
Run one server and want the least work Forge ($12-$39/mo) Forge's pricing page lists zero-downtime deployments on all three plans
Deploy one project to several servers Envoyer ($10-$50/mo) Forge's own FAQ says Forge does zero-downtime on one server only, "unlike Envoyer which can deploy one project to multiple servers"
Want it free and own your SSH box Deployer or GitHub Actions + a script You own the pipeline; script below
Have one tiny side project Skip all of them git pull plus maintenance mode is fine until downtime costs money

No tool here rolls back your database. That's the real gap, and Part 3 covers it.

Next step: copy the workflow from Part 1, then the deploy script from Part 2, and rehearse one rollback on staging before you pay for anything.

Part 1: run Laravel tests on every push with GitHub Actions

Laravel's deployment docs list PHP 8.2 as the minimum for Laravel 12, so test against the versions you run in production. shivammathur/setup-php is the usual action; its README says to use the v2 tag and lists PHP 8.4 and 8.5 as actively supported (source).

I did not run this workflow on a live GitHub runner, so treat it as a starting point and check your first run. The env block exists because your .env.example may point DB_CONNECTION at MySQL, which would make php artisan test fail on a runner with no database; check yours and either keep SQLite here or add a MySQL service:

name: ci
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    env:
      DB_CONNECTION: sqlite
      DB_DATABASE: ':memory:'
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          coverage: none        # xdebug/pcov only if you report coverage
      - run: composer install --no-interaction --prefer-dist
      - run: cp .env.example .env && php artisan key:generate
      - run: php artisan test --compact

For what belongs in that test job, see testing strategy for Laravel: unit, feature and browser tests that actually help.

What it costs. GitHub's billing docs list 2,000 free private-repo minutes on Free and 3,000 on Pro/Team, then $0.006 per minute on a standard 2-core Linux runner; public repos are free (source). Assume, as an illustration and not a measurement, a 4-minute job: 2,000 free minutes cover 500 runs. The push, pull_request trigger fires on both events, so a branch with an open PR can use two runs per push, which brings that to roughly 250 pushes. Beyond the free minutes, 1,000 extra minutes at $0.006 is $6.

Part 2: a zero-downtime Laravel deploy script

Every zero-downtime tool uses the same trick: each deploy builds into a new directory, and a current symlink points at the live one. Deployer documents exactly that layout: numbered folders under releases, a current symlink, and a shared directory for things that persist (Deployer docs). Its Laravel recipe runs vendors, the cache commands and migrate before deploy:publish, which contains deploy:symlink, so new code only goes live after the rest succeeded.

Below is the script I ran. It clones a git ref, runs composer install --no-dev, migrate --force and optimize, boots the new release with php artisan serve on a private port, probes /up, and only then swaps the symlink. The swap function creates a temporary symlink and mv -T renames it over current, rather than re-pointing current in place; I did not benchmark the difference for this post.

One-time setup: shared/ holds .env, storage/ and, for SQLite, database/. My test used SQLite in WSL Ubuntu (PHP 8.5.4, Composer 2.9.5, Laravel 13.37.0 from composer create-project); with MySQL, drop the database/ part.

#!/usr/bin/env bash
# usage: deploy.sh <base> <git-ref> | deploy.sh <base> rollback
# <base>/shared must hold .env, database/ and storage/ (created once, see below)
set -euo pipefail
base=$1; cmd=$2; keep=3; repo=${REPO:?set REPO to your git URL}
swap() { ln -sfn "$1" "$base/current.tmp" && mv -Tf "$base/current.tmp" "$base/current"; }
if [ "$cmd" = rollback ]; then
  cur=$(basename "$(readlink -f "$base/current")")
  prev=$(ls -1 "$base/releases" | sort | grep -B1 -x "$cur" | head -1)
  [ "$prev" != "$cur" ] || { echo "no earlier release"; exit 1; }
  swap "$base/releases/$prev"
  (cd "$base/current" && php artisan reload)
  echo "rolled back $cur -> $prev"; exit 0
fi
rel="$base/releases/$(date +%Y%m%d%H%M%S)-$cmd"
fail() { echo "deploy of $cmd failed ($1); live release unchanged"; rm -rf "$rel"; exit 1; }
git clone -q -c advice.detachedHead=false --depth 1 --branch "$cmd" "$repo" "$rel" || fail clone
cd "$rel"
ln -sfn "$base/shared/.env" .env
rm -rf storage && ln -sfn "$base/shared/storage" storage
composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader -q || fail composer
php artisan migrate --force || fail migrate
php artisan optimize || fail optimize
# health gate: boot the NEW release on a private port and probe /up before the swap
php artisan serve --host=127.0.0.1 --port=8099 >/dev/null 2>&1 & srv=$!
trap 'pkill -P $srv 2>/dev/null; kill $srv 2>/dev/null' EXIT
ok=0; for _ in 1 2 3 4 5; do sleep 1; curl -fsS --max-time 3 http://127.0.0.1:8099/up >/dev/null 2>&1 && { ok=1; break; }; done
[ $ok = 1 ] || fail "/up did not return 200"
pkill -P $srv 2>/dev/null; kill $srv 2>/dev/null; trap - EXIT
swap "$rel"
php artisan reload
ls -1dt "$base"/releases/* | tail -n +$((keep+1)) | xargs -r rm -rf
echo "live: $(basename "$rel")"

The test repo had tags v1-v4 (each appends a comment to routes/web.php) and a bad tag whose AppServiceProvider listens for Laravel's DiagnosingHealth event and throws, so /up returns 500. Output, filtered to the summary lines (the full run also prints each migrate/optimize/reload step):

  Creating migration table ...................................... 28.46ms DONE
live: 20261018091025-v1
  releases: 20261018091025-v1
   INFO  Nothing to migrate.
live: 20261018091032-v2
  releases: 20261018091025-v1 20261018091032-v2
   INFO  Nothing to migrate.
live: 20261018091038-v3
  releases: 20261018091025-v1 20261018091032-v2 20261018091038-v3
   INFO  Nothing to migrate.
live: 20261018091045-v4
  releases: 20261018091032-v2 20261018091038-v3 20261018091045-v4
---
deploy of bad failed (/up did not return 200); live release unchanged
current -> 20261018091045-v4
  releases: 20261018091032-v2 20261018091038-v3 20261018091045-v4
---
rolled back 20261018091045-v4 -> 20261018091038-v3
// v3

Pruning is visible: v1 drops out after the fourth deploy. The bad deploy was caught by the /up probe, its directory was removed, and current still pointed at v4. Rollback is one symlink swap with no rebuild. This ran locally with file:// as the repo and no Nginx or PHP-FPM in front, so it shows the mechanics, not a production web stack. The migrate step ran before the gate in the bad deploy too; that matters in Part 3.

Deployer instead of the script

Deployer's recipe page shows require 'recipe/laravel.php'; and its getting-started page shows the host(...)->set('remote_user', ...)->set('deploy_path', ...) form. Neither page shows a repository setting and I did not run Deployer, so I'm not printing a combined deploy.php I can't vouch for; build it from those two pages. Deployer's rollback task is described as "Rollbacks to the previous release", and it marks the current release as bad by creating a BAD_RELEASE file.

  • Long-running processes keep the old code. Laravel's docs say queue workers, Reverb and Octane should be reloaded after a deploy, and that php artisan reload terminates them; outside Laravel Cloud you configure a process monitor to restart them (docs). For in-flight jobs, see graceful shutdown patterns for Laravel queue workers.
  • PHP-FPM, $realpath_root and OPcache. The Nginx config in Laravel's deployment docs uses fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;, so keep that line if you serve through a symlink. The PHP manual says that with opcache.validate_timestamps on, OPcache checks for updated scripts every opcache.revalidate_freq seconds, and with it off you must reset OPcache or restart the web server. If you turned timestamp validation off for speed, reload PHP-FPM after the swap. I did not test OPcache behaviour in this post.

The health route

Laravel's docs say /up returns 200 if the app booted without exceptions and 500 otherwise, and that each request dispatches an Illuminate\Foundation\Events\DiagnosingHealth event; a listener can check your database or cache and throw (docs). That is exactly what the bad tag above does.

Part 3: Laravel rollback and the migration problem

Swapping the symlink back is one command. It does not undo a column drop. If release N+1 ran migrate and then you roll back to N, release N is now running against a schema it has never seen.

Rules that keep rollbacks honest:

  1. Expand, then contract. Ship additive migrations (new nullable column, new table) in one release. Remove old columns two releases later.
  2. Never rename or drop in the same deploy as the code that stops using the column.
  3. Run migrate --force before the swap, never after. If it fails, the old release keeps serving.
  4. Treat migrate:rollback as an emergency tool. It only works if every down() is correct.

For a different failure mode, jobs that die half-way, see Laravel queue batches: recovering failed jobs.

Forge vs Envoyer pricing, with pros and cons

Prices read from the vendors' pages on 2026-10-18, monthly billing. Forge's page has a Monthly/Annual toggle but the text I could read showed only monthly figures, so I have no annual Forge price. Envoyer's page doesn't mention annual billing at all, so I can't say whether an annual plan exists. Check both before you commit.

Option Price (monthly) Zero-downtime Servers Rollback
Forge Hobby $12 Listed Unlimited Laravel VPS servers + 1 external server Not on pricing page
Forge Growth $19 Listed Unlimited servers Not on pricing page
Forge Business $39 Listed Unlimited servers Not on pricing page
Envoyer Basic $10 (10 projects, 10-minute deploy time) Yes Page lists "Deploy To Multiple Servers" Page lists "Seamless Deployment Rollbacks"
Envoyer Plus $20 (20 projects, 10-minute) Yes same same
Envoyer Premium $50 (unlimited projects, 15-minute) Yes same same
Deployer Free (open source) Release layout built in Multi-host config rollback task
Actions + script Free tier, then $0.006/min You build it You build it Script above

Envoyer's page lists those features in a general list, and each plan says "All features and tools"; it does not break them out per plan, so treat per-plan availability as implied, not stated. Forge's pricing page says zero-downtime deployments cover one server only, and that new Forge subscriptions make a separate Envoyer subscription unnecessary while existing Envoyer subscriptions keep working. Envoyer's 10-minute deploy time on Basic and Plus means a slow composer install or asset build on the server can hit it; build assets in CI instead.

Pros and cons

  • Forge. Pro: provisions servers (Laravel's docs say it installs Nginx, MySQL, Redis and similar), zero-downtime included in every plan. Con: single-server zero-downtime only; rollback isn't on the pricing page.
  • Envoyer. Pro: multi-server deploys, health checks and rollbacks are its headline features. Con: deploy-time cap (10 or 15 minutes), project limits on Basic and Plus, and Forge says an extra subscription isn't needed for single-server setups.
  • Deployer. Pro: free, release layout and rollback task built in. Con: you maintain deploy.php and the SSH access yourself.
  • Actions + script. Pro: free tier and full control. Con: everything in Part 2 is yours to maintain; I tested it on one machine only.

Hidden gotchas

  • config:cache and env(). Per Laravel's docs, once config is cached .env isn't loaded and env() calls outside config files return null (docs). This breaks deploys that passed tests locally.
  • Shared storage. Anything written inside a release directory vanishes when that release is pruned. Use the shared storage and .env, as the script does.

Who should buy what

  • One VPS, one app, you'd rather not SSH: Forge Hobby at $12.
  • Several web servers behind a load balancer: Envoyer, or Deployer from Actions.
  • You already own your pipeline: Actions plus Deployer or the script above.
  • Skip all of them if a short maintenance-mode window on a side project is acceptable. Running containers instead? See dockerizing a Laravel app.

Your next step: make the Part 1 workflow a required check on main, then run the Part 2 script against staging and rehearse one rollback. Only then decide whether Forge or Envoyer is worth the monthly fee.

Bekzod Erkinov

Bekzod Erkinov

Author

Founder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.

Free guide

Get the AI-Assisted Developer's Field Guide

The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.

Comments (0)

Please log in to leave a comment.

Log In

Related Articles

Don't miss the next deep dive

Get one well-researched tutorial in your inbox each week. No spam, unsubscribe anytime.