The Dev Log › Technology
Deploying Laravel With Zero Downtime: CI/CD, Migrations and Queues
By Jezer Niel Blanca, Full Stack Developer ·
·
7 min read
How to deploy Laravel without downtime: tests in CI, atomic release folders, shared storage, expand-and-contract migrations, queue worker restarts and quick rollbacks.
If deploying your Laravel app means putting up a maintenance page, uploading files and hoping nothing breaks, there's a better way. Zero-downtime deployment means users keep working while a new version goes live, and if something goes wrong you can roll back in seconds. It sounds like something only large teams need, but the core idea is simple enough for any project. In this post I'll walk through how I set it up: automated tests in CI, atomic release folders, safe database migrations, and restarting queue workers so they pick up new code.
Why Traditional Deploys Cause Downtime
The classic approach is to pull new code into the live directory, run composer install, build assets and run migrations, all in place. During those minutes the app is in a mixed state. Some files are new, some are old, dependencies may be half-installed, and the database may not match the code. Users hitting the site at that moment can see errors.
Zero-downtime deployment fixes this with one principle:
Build the new release completely somewhere else, then switch to it in a single, instant step. Users should only ever see the old version or the new one, never a half-finished mix.
The Release Folder Structure
The standard pattern uses a folder per release and a symlink that points to the live one. On the server it looks like this:
/var/www/app
├── current -> releases/20260101120000
├── releases
│ ├── 20260101113000
│ └── 20260101120000
└── shared
├── .env
└── storage
Here's what each part does:
releases holds each deployed version in its own timestamped folder.
current is a symlink to the live release. Your web server's document root points at current/public.
shared holds everything that must persist between releases, mainly .env and the storage directory with logs, sessions and uploads.
Switching releases is just repointing the symlink, which is effectively instant. Rolling back is repointing it at the previous folder.
A Deploy Script You Can Read
Tools like Laravel Forge, Envoyer and Deployer automate this pattern, and they're worth using. It's still valuable to understand what they do, so here's a plain Bash version:
#!/usr/bin/env bash
set -euo pipefail
APP_DIR="/var/www/app"
RELEASE="$(date +%Y%m%d%H%M%S)"
RELEASE_DIR="$APP_DIR/releases/$RELEASE"
git clone --depth 1 --branch main git@github.com:your-org/your-app.git "$RELEASE_DIR"
cd "$RELEASE_DIR"
ln -s "$APP_DIR/shared/.env" .env
rm -rf storage
ln -s "$APP_DIR/shared/storage" storage
composer install --no-dev --optimize-autoloader --no-interaction
npm ci
npm run build
php artisan migrate --force
php artisan optimize
ln -sfn "$RELEASE_DIR" "$APP_DIR/current"
php artisan queue:restart
ls -1dt "$APP_DIR"/releases/* | tail -n +6 | xargs -r rm -rf
Walking through the important lines:
set -euo pipefail stops the script at the first error, so a failed step never leads to a switch.
- Shared links connect the new release to the persistent
.env and storage.
composer install --no-dev installs production dependencies only, with an optimised autoloader.
npm run build compiles Vite assets inside the new release, so the live site keeps its old assets until the switch.
php artisan optimize caches config, routes, views and events for speed.
ln -sfn swaps the symlink to go live.
queue:restart tells workers to finish their current job and restart on the new code.
- The last line keeps the five most recent releases for rollbacks and deletes older ones.
Mind the PHP Cache
PHP-FPM with OPcache can keep serving cached files from the old path after the symlink changes. Most setups handle this by reloading PHP-FPM after the switch, for example with sudo systemctl reload php8.3-fpm, so new requests resolve the new release. A reload finishes in-flight requests gracefully rather than cutting them off.
Running Tests Before Anything Ships
A deploy pipeline should refuse to ship broken code. I run the test suite in CI on every push and only deploy when it passes. A minimal GitHub Actions workflow looks like this:
name: Test and deploy
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- run: composer install --no-interaction --prefer-dist
- run: cp .env.example .env && php artisan key:generate
- run: npm ci && npm run build
- run: php artisan test --compact
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- name: Run deploy script over SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: bash /var/www/app/deploy.sh
The needs: test line is the key. The deploy job only runs after tests succeed, and secrets like the SSH key live in the repository's encrypted secrets, never in the code.
Migrations Without Breaking the Live App
Migrations are the trickiest part, because for a short time the old code runs against the new database schema. A migration that renames or drops a column the old code still uses will cause errors during that window.
Expand, Then Contract
The safe approach is to split breaking schema changes across multiple deploys:
- Expand. Add the new column or table without removing anything. Deploy code that writes to both old and new structures.
- Migrate data. Backfill the new column, ideally with a queued job for large tables.
- Switch. Deploy code that reads from the new structure only.
- Contract. In a later deploy, drop the old column once nothing uses it.
It's more steps, but each deploy is backward compatible with the code that's live when it runs.
Other Migration Habits
- Add new columns as nullable or with a default, so existing inserts don't fail.
- Be careful with indexes on large tables, since some operations can lock the table.
- Never edit a migration that has already run in production. Write a new one instead.
- Test migrations against a copy of production data when the change is large.
Queues, Scheduler and Health Checks
Background processes need attention during deploys too.
Queue Workers
Queue workers are long-running processes that load your code once, so without a restart they'd keep running old code indefinitely. php artisan queue:restart signals them to exit gracefully after their current job, and your process monitor, such as Supervisor, starts them again on the new release. If you use Horizon, php artisan horizon:terminate does the same job.
Make sure Supervisor's command points at the current symlink path so restarted workers load the new code.
The Scheduler
Your cron entry should also run through the current path, like php /var/www/app/current/artisan schedule:run. That way the next scheduled run automatically uses the new release.
Health Checks and Rollback
New Laravel apps include a health route at /up, configured in bootstrap/app.php. After switching releases, have your pipeline request it and fail loudly if it doesn't return a success response. If something is wrong, rolling back is just pointing current at the previous release folder and restarting workers.
Wrapping up
Zero-downtime deployment for Laravel comes down to building each release in its own folder, sharing .env and storage, running tests in CI before anything ships, switching with a single symlink, handling migrations with the expand and contract pattern, and restarting queue workers afterwards. Once it's in place, deploys become routine instead of stressful. If you want a Laravel product with a reliable deployment pipeline from day one, I'd love to build it with you and my team.
Tags: Laravel, Deployment, CI/CD, DevOps