Bekzod Erkinov
Listen to Article
Loading...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.
Things the symlink doesn't fix
- 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 reloadterminates 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_rootand OPcache. The Nginx config in Laravel's deployment docs usesfastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;, so keep that line if you serve through a symlink. The PHP manual says that withopcache.validate_timestampson, OPcache checks for updated scripts everyopcache.revalidate_freqseconds, 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:
- Expand, then contract. Ship additive migrations (new nullable column, new table) in one release. Remove old columns two releases later.
- Never rename or drop in the same deploy as the code that stops using the column.
- Run
migrate --forcebefore the swap, never after. If it fails, the old release keeps serving. - Treat
migrate:rollbackas an emergency tool. It only works if everydown()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.phpand 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:cacheandenv(). Per Laravel's docs, once config is cached.envisn't loaded andenv()calls outside config files returnnull(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
storageand.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.
Keep reading
Complete Solution: Scaling a Node.js Application with Kubernetes and Docker
29 min · 928 views
Data ScienceMastering CI/CD Pipelines with Jenkins and Docker: A Deep Dive into Automated Deployment and Testing
14 min · 904 views
Web DevelopmentMastering Multi-Tenant SaaS Architecture Patterns for Enterprise Applications
27 min · 880 views
Bekzod Erkinov
AuthorFounder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.
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