Following laravel deployment best practices means your users should never see a broken page during an update. Yet most tutorials show you running git pull, composer install, and php artisan migrate directly on production — a process that can leave your site broken for 30-60 seconds on every deploy. This guide covers zero-downtime deployment techniques that keep your Laravel app running smoothly while you ship changes.
Why Standard Deployments Cause Downtime
A typical deployment script runs these steps sequentially on your live server:
git pull origin main
composer install --no-dev
php artisan migrate --force
php artisan config:cache
npm run build
php artisan queue:restart
During this process, several things can go wrong:
composer installtemporarily removes autoload files — requests fail with class-not-found errorsnpm run buildtakes 10-30 seconds — old assets are gone, new ones aren't ready- Migrations might lock database tables — queries queue up or timeout
- Config cache is cleared before the new one is built — your app reads raw
.envfiles briefly
Each of these creates a window where your site is partially or fully broken.
The Symlink Strategy: How Zero-Downtime Works
The core idea behind zero-downtime deployment is simple: build everything in a new directory, then switch to it atomically.
/var/www/
├── releases/
│ ├── 20260315_001/ ← previous release
│ ├── 20260315_042/ ← current live release
│ └── 20260316_011/ ← new release (being built)
├── shared/
│ ├── .env
│ ├── storage/
│ └── node_modules/
└── current → releases/20260315_042/ ← symlink (atomic switch)
Your Nginx points to /var/www/current/public. When the new release is fully built and tested, you swap the symlink — a single atomic filesystem operation that takes microseconds.
Setting Up Zero-Downtime Laravel Deployment
Step 1: Create the Directory Structure
sudo mkdir -p /var/www/{releases,shared/storage}
sudo chown -R www-data:www-data /var/www/
Move your .env file and storage directory to the shared location:
mv /var/www/current/.env /var/www/shared/.env
mv /var/www/current/storage/* /var/www/shared/storage/
Step 2: The Zero-Downtime Deploy Script
#!/bin/bash
set -e
APP_DIR="/var/www"
REPO="git@github.com:youruser/yourapp.git"
RELEASE="$(date +%Y%m%d_%H%M%S)"
RELEASE_DIR="$APP_DIR/releases/$RELEASE"
SHARED_DIR="$APP_DIR/shared"
echo "📦 Creating release: $RELEASE"
git clone --depth 1 $REPO "$RELEASE_DIR"
echo "🔗 Linking shared resources..."
ln -sf "$SHARED_DIR/.env" "$RELEASE_DIR/.env"
rm -rf "$RELEASE_DIR/storage"
ln -sf "$SHARED_DIR/storage" "$RELEASE_DIR/storage"
echo "📥 Installing PHP dependencies..."
cd "$RELEASE_DIR"
composer install --optimize-autoloader --no-dev --no-interaction
echo "🏗️ Building frontend assets..."
npm ci --prefer-offline
npm run build
echo "🗄️ Running migrations..."
php artisan migrate --force
echo "⚡ Caching configuration..."
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
echo "🔄 Switching symlink (zero-downtime moment)..."
ln -sfn "$RELEASE_DIR" "$APP_DIR/current"
echo "♻️ Reloading PHP-FPM..."
sudo systemctl reload php8.2-fpm
echo "🔄 Restarting queue workers..."
php artisan queue:restart
echo "🧹 Cleaning old releases (keeping last 5)..."
cd "$APP_DIR/releases"
ls -dt */ | tail -n +6 | xargs rm -rf
echo "✅ Deployed $RELEASE successfully!"
The critical line is ln -sfn — it creates a new symlink atomically, replacing the old one in a single operation. Your site never serves a partially-built release.
Step 3: Update Nginx Configuration
Point Nginx to the symlink, not a specific release:
server {
listen 443 ssl;
server_name yourdomain.com;
root /var/www/current/public;
# ... rest of your config
}
Since current is a symlink, Nginx follows it to whatever release is active. When the symlink changes, new requests hit the new release. If you need to learn more about the full Laravel deployment checklist, we have a detailed guide on production setup.
Handling Database Migrations Safely
Migrations are the trickiest part of zero-downtime deploys. A migration that drops a column will break the old release while the new one is being built.
The Expand-Contract Pattern
Split breaking changes into two deployments:
Deploy 1 (Expand): Add new columns/tables without removing old ones
// Migration: Add new_email column alongside email
Schema::table('users', function (Blueprint $table) {
$table->string('new_email')->nullable()->after('email');
});
Deploy 2 (Contract): Remove old columns after all code uses the new ones
// Only after Deploy 1 is live and working
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('email');
$table->renameColumn('new_email', 'email');
});
This pattern ensures both old and new code can run simultaneously during the transition.
Migration Timeout Protection
Long-running migrations can lock tables. Add a timeout:
public function up()
{
DB::statement('SET lock_wait_timeout = 5');
Schema::table('orders', function (Blueprint $table) {
$table->index('created_at');
});
}
Automated Rollback
One major advantage of the symlink approach: instant rollbacks.
#!/bin/bash
# rollback.sh — Switch to previous release
APP_DIR="/var/www"
PREVIOUS=$(ls -dt "$APP_DIR/releases"/*/ | sed -n '2p')
if [ -z "$PREVIOUS" ]; then
echo "❌ No previous release found"
exit 1
fi
echo "⏪ Rolling back to: $PREVIOUS"
ln -sfn "$PREVIOUS" "$APP_DIR/current"
sudo systemctl reload php8.2-fpm
php artisan queue:restart
echo "✅ Rollback complete"
One command and your site is back on the previous working version — no git revert, no redeployment. For complex deployments, having a staging environment to test changes before production is equally important.
Tools That Automate This
If you prefer not to maintain custom scripts, several tools implement these laravel deployment best practices automatically:
Laravel Envoyer ($12/month) — Built by the Laravel team. Handles symlink deployments, health checks, and rollbacks with a clean UI.
Deployer (free, open source) — PHP-based deployment tool with built-in Laravel recipes:
# Install
composer global require deployer/deployer
# Initialize Laravel recipe
dep init --template=Laravel
GitHub Actions + Custom Script — Free CI/CD that runs your deploy script on push:
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: deploy
key: ${{ secrets.SSH_KEY }}
script: /var/www/deploy.sh
FAQ
What is zero-downtime deployment in Laravel?
Zero-downtime deployment uses a symlink strategy to build new releases in a separate directory and switch to them atomically. Users never see a broken or partially-deployed application.
Can I do zero-downtime deploys on shared hosting?
No. You need SSH access and the ability to create symlinks, which requires VPS or dedicated hosting. Shared hosting doesn't provide this level of control.
How do I handle storage files across releases?
Use a shared storage directory symlinked into each release. This ensures uploaded files, logs, and cached data persist across deployments without duplication.
What if a migration fails during deployment?
The old symlink remains active, so your site keeps running on the previous release. Fix the migration, test it locally or on staging, and redeploy. The failed release directory can be deleted.
Deploy Laravel with Zero Downtime on DeployBase
Implementing laravel deployment best practices with zero-downtime deploys requires a server you can fully control. At DeployBase, our VPS plans give you root SSH access, NVMe SSD storage for fast builds, and the flexibility to set up symlink-based deployments exactly as described above. Starting at $5/month with 24/7 support.
Get your VPS at DeployBase → — professional hosting for professional deployments.



