Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 060 / 100

Deploying Laravel to production: lessons from production

A reliable deployment checklist.

Expona Admin8 min read

Deploying Laravel to production: lessons from production

Getting Laravel code from your laptop to a live server sounds straightforward until something breaks at 2 AM and you're scrambling to rollback. We've all felt that anxiety when a deploy doesn't go as planned—missing dependencies, stale cache, queue workers processing old jobs, or worse, users hitting a broken site during the switchover. Over years of shipping Laravel applications in e-commerce, SaaS, and real-time trading systems, we've learned that production deployments demand more than just pushing code. They require discipline, clear steps, and automation that removes human error from the equation.

This guide walks through a battle-tested deployment checklist that treats production with the seriousness it deserves. We'll cover the build phase, optimization, worker management, and zero-downtime strategies that keep your application running while you deploy.

Build: Composer install --no-dev --optimize-autoloader

The build phase is where we prepare Laravel's dependencies for the production environment. This isn't just about installing packages—it's about installing the right packages in the right way.

In development, you have testing frameworks, debugging tools, and development dependencies installed. In production, every megabyte matters, and unused packages slow down autoloading and increase your attack surface. The command composer install --no-dev --optimize-autoloader does two critical things:

  • --no-dev excludes packages only needed during development (PHPUnit, Faker, Tinker, debugging tools).
  • --optimize-autoloader generates a classmap that PHP can load instantly instead of searching the filesystem.

Here's a typical build step in a deployment script:

php
#!/bin/bash
set -e

# Pull latest code
cd /var/www/laravel-app
git fetch origin
git checkout origin/main

# Install production dependencies
composer install --no-dev --optimize-autoloader

# Copy .env if it doesn't exist
if [ ! -f .env ]; then
    cp .env.production .env
fi

echo "Build complete"

The set -e ensures the script stops immediately if any command fails. This prevents partial deployments from proceeding silently.

In a real scenario, you might deploy to a staging environment first to catch composer conflicts or missing extensions. We've seen situations where a developer added a package requiring a PHP extension not installed on production, and the build phase caught it before affecting live users.

Always verify that your production server has the correct PHP version and all required extensions. Run php -m and php -v to check before deploying critical changes.

Optimize: php artisan optimize and migrate --force

Once dependencies are installed, Laravel needs to prepare itself for production workload. The optimization phase combines config caching, route caching, and database migrations into a single coherent step.

php artisan optimize performs several optimizations:

  • Caches configuration files so Laravel doesn't parse config files on every request.
  • Caches routes for faster route matching.
  • Caches service providers and events.
  • Generates optimized class loader maps.

Here's a deployment script segment combining optimization and migrations:

php
#!/bin/bash

# Optimize Laravel
php artisan config:cache
php artisan route:cache
php artisan view:cache

# Run migrations with --force to skip confirmation in CI/CD
php artisan migrate --force

# Clear old caches
php artisan cache:clear
php artisan view:clear

echo "Optimization and migrations complete"

The --force flag on migrate is essential for CI/CD pipelines because the command normally prompts for confirmation, which would hang your deployment. Without it, an automated deployment will timeout waiting for user input.

However, be careful with migrations in high-traffic systems. Long-running migrations can lock tables and cause downtime. We recommend:

  • Writing migrations that don't lock tables for extended periods.
  • Using ALTER TABLE ... ALGORITHM=INPLACE syntax for MySQL when possible.
  • Running large schema changes during a maintenance window if necessary.
  • Testing migrations on a staging database that mirrors production size and structure.

A common pattern is to deploy code first, then run migrations in a separate step with monitoring to catch issues early.

Workers: Restart queue workers on deploy with queue:restart

Many Laravel applications use queue workers to handle background jobs—sending emails, processing images, syncing data with external APIs. When you deploy new code, those workers are still running the old code and will continue to do so until restarted.

If a worker picks up a job after deployment, it might be working with an old class definition that no longer exists, or worse, running business logic that conflicts with your new schema.

php artisan queue:restart signals all queue workers to stop gracefully after they finish their current job, then your process manager (Supervisor, systemd, etc.) restarts them with the new code.

Here's how to integrate it into your deployment:

php
#!/bin/bash

# ... build and optimize steps ...

# Gracefully restart queue workers
php artisan queue:restart

# Wait for workers to restart
sleep 5

# Verify workers are running
supervisorctl status laravel-worker

echo "Queue workers restarted"

The beauty of queue:restart is that it doesn't drop jobs. It sets a flag that tells workers "finish your current job, then exit." Supervisor (or your process manager) sees the exit, respects the graceful shutdown, and starts new worker processes automatically.

In production systems handling millions of messages daily, we've seen deployments that skipped this step cause silent failures where old worker processes were processing jobs with new code—a recipe for data corruption.

If you're not using queue workers, you can skip this step. But if you're using Laravel's Queue, Horizon, or any asynchronous job processing, this is non-negotiable.

The most common deployment strategy we use in production is symlink-based zero-downtime deploys. Instead of updating code in place (which causes brief moments where code is partially deployed), we deploy to a completely separate folder, then atomically switch a symlink.

The structure looks like this:

php
/var/www/
├── app -> releases/20250120_143022  # symlink to current release
├── shared/                          # shared files (logs, .env, uploads)
│   ├── .env
│   ├── storage/
│   └── bootstrap/cache/
└── releases/
    ├── 20250120_143022/             # current live release
    ├── 20250120_142015/             # previous release (for quick rollback)
    └── 20250119_181500/

Here's a complete deployment script using this pattern:

php
#!/bin/bash
set -e

DEPLOY_PATH="/var/www"
SHARED_PATH="$DEPLOY_PATH/shared"
RELEASES_PATH="$DEPLOY_PATH/releases"
CURRENT_LINK="$DEPLOY_PATH/app"
RELEASE_NAME=$(date +%Y%m%d_%H%M%S)
RELEASE_PATH="$RELEASES_PATH/$RELEASE_NAME"

# Step 1: Create new release directory
mkdir -p "$RELEASE_PATH"
cd "$RELEASE_PATH"

# Step 2: Clone code
git clone --depth 1 --branch main https://github.com/company/app.git .

# Step 3: Link shared files
ln -s "$SHARED_PATH/.env" .env
ln -s "$SHARED_PATH/storage" storage
ln -s "$SHARED_PATH/bootstrap/cache" bootstrap/cache

# Step 4: Build
composer install --no-dev --optimize-autoloader

# Step 5: Optimize
php artisan config:cache
php artisan route:cache
php artisan view:cache

# Step 6: Database migrations (if needed)
php artisan migrate --force

# Step 7: Restart workers
php artisan queue:restart

# Step 8: Switch symlink (atomic operation)
ln -sfn "$RELEASE_PATH" "$CURRENT_LINK.tmp"
mv -Tf "$CURRENT_LINK.tmp" "$CURRENT_LINK"

# Step 9: Cleanup old releases (keep last 3)
cd "$RELEASES_PATH"
ls -t | tail -n +4 | xargs -r rm -rf

echo "Deployment complete: $RELEASE_NAME"

Why is this zero-downtime? Because the symlink switch (Step 8) is atomic at the filesystem level. Web servers reading from the symlink see it point to the old release until the exact moment the switch happens, then they see the new one. There's no intermediate state where code is partially deployed.

Users don't experience downtime because their HTTP requests continue being served by the old release until the symlink flips, then subsequent requests hit the new code. The brevity of the switch means PHP-FPM or your app server doesn't need to reload—the old processes can even finish in-flight requests while new ones begin on the new code.

We've deployed this way in high-traffic systems (100k+ daily active users) with zero observed downtime, even during database migrations.

Common mistakes

Deploying without testing migrations locally. A migration that works on your laptop with a small dataset can lock a production table for 10 minutes with 50GB of data. Always test migrations on a clone of production data first.

Forgetting to clear caches. Laravel caches configuration, routes, and views. If you don't clear these, you're running new code with old configuration or routes. php artisan optimize:clear clears everything.

Not restarting queue workers. Jobs in the queue continue processing with old code, causing unpredictable failures and data issues.

Deploying during peak traffic. Migrations and optimizations run faster when the database isn't under load. Whenever possible, schedule deployments during low-traffic windows.

Removing the previous release immediately. Keep at least 2-3 previous releases so you can rollback in seconds if something breaks. Disk space is cheap; downtime is expensive.

Key takeaways

  • Build with composer install --no-dev --optimize-autoloader to exclude development packages and speed up autoloading.
  • Optimize with php artisan optimize and test migrations on production-like data before deploying.
  • Restart queue workers with php artisan queue:restart so they pick up new code instead of running stale logic.
  • Deploy to a new release folder and switch via symlink to achieve true zero-downtime deployments.
  • Automate every step in your deployment script to eliminate human error and create repeatability.
  • Keep previous releases available for rapid rollback.
  • Test the entire deployment process in staging before running it on production.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles