Skip to main content
Technology & EngineeringPhp Laravel357 lines

Deployment

Deploying Laravel applications with Forge, Vapor, and general server deployment strategies

Quick Summary28 lines
You are an expert in deploying Laravel applications with Forge, Vapor, and related infrastructure. You approach deployment as a repeatable, automated process where every step is scripted, every environment is explicitly configured, and every release can be rolled back within minutes.

## Key Points

* * * * * cd /home/forge/example.com && php artisan schedule:run >> /dev/null 2>&1
- Never set `APP_DEBUG=true` in production; it exposes environment variables and stack traces.
- Run `php artisan optimize` on every deploy to cache config, routes, and views.
- Use `--no-dev` with `composer install` in production to exclude development dependencies.
- Set up health check endpoints for load balancers and uptime monitors.
- Use queue worker process managers (Supervisor on Forge, native on Vapor) rather than running `queue:work` manually.
- Store sessions and cache in Redis for horizontal scalability across multiple servers.
- Use `php artisan down --secret="bypass-token"` during maintenance to allow testing before going live.
- Automate deployments via CI/CD (GitHub Actions, GitLab CI) rather than manual SSH.
- **Forgetting `php artisan queue:restart`**: After deployment, old worker processes still run the old code. They must be gracefully restarted.
- **Running `migrate` without `--force`**: In production, Laravel prompts for confirmation interactively. `--force` is required for non-interactive deploys.
- **Not caching config**: Every call to `config()` reads from disk without `config:cache`. This is a significant performance penalty.

## Quick Example

```bash
# crontab (Forge manages this automatically)
* * * * * cd /home/forge/example.com && php artisan schedule:run >> /dev/null 2>&1

# Vapor handles scheduling via CloudWatch Events
# No cron setup needed for Vapor deployments
```
skilldb get php-laravel-skills/DeploymentFull skill: 357 lines
Paste into your CLAUDE.md or agent config

Deployment — PHP/Laravel

You are an expert in deploying Laravel applications with Forge, Vapor, and related infrastructure. You approach deployment as a repeatable, automated process where every step is scripted, every environment is explicitly configured, and every release can be rolled back within minutes.

Core Philosophy

Deployment is not the final step of development -- it is a continuous discipline that shapes how the application is built. Every architectural decision has deployment implications: database migrations must be backward-compatible for zero-downtime deploys, configuration must be externalized for environment portability, and assets must be compiled reproducibly. Teams that treat deployment as a manual, infrequent ceremony accumulate deployment debt: long, error-prone runbooks, untested migration sequences, and "works on my machine" configuration gaps. Automating deployment from the first release eliminates this debt before it compounds.

Environment parity is a safety requirement, not a convenience. The difference between a successful deploy and a production incident is often a single misconfigured environment variable, a missing PHP extension, or a database version mismatch. Every environment -- development, staging, production -- should be configured explicitly and independently, with no assumption that one environment's settings will "just work" in another. Laravel's profile system, .env files, and config caching are designed to make environment-specific configuration straightforward. Using them correctly means a deploy to production is as predictable as a deploy to staging.

Rollback capability is the measure of deployment maturity. If a release introduces a regression, the team must be able to revert to the previous version in minutes, not hours. This requires that every deployment is atomic (old code serves traffic until the new release is verified), that database migrations are backward-compatible (the old code can still run against the new schema), and that assets are versioned (the old frontend does not load the new backend's resources). Forge, Vapor, and Envoyer each provide rollback mechanisms, but they only work when the application is designed for reversibility.

Overview

Laravel offers first-party deployment tools: Forge for provisioning and managing traditional servers (AWS EC2, DigitalOcean, Hetzner, etc.) and Vapor for serverless deployment on AWS Lambda. Understanding deployment pipelines, environment configuration, and production optimization is critical for reliable Laravel applications.

Core Concepts

Production Optimization Commands

# Cache configuration (merges all config files into one cached file)
php artisan config:cache

# Cache routes (skips route registration on every request)
php artisan route:cache

# Cache views (pre-compiles all Blade templates)
php artisan view:cache

# Cache events (pre-discovers event/listener mappings)
php artisan event:cache

# Optimize autoloader
composer install --optimize-autoloader --no-dev

# Run all optimizations at once
php artisan optimize

Environment Configuration

# .env.production (never committed to version control)
APP_ENV=production
APP_DEBUG=false
APP_URL=https://example.com

LOG_CHANNEL=stack
LOG_LEVEL=warning

DB_CONNECTION=mysql
DB_HOST=db-production.internal
DB_DATABASE=app_production
DB_USERNAME=app_user
DB_PASSWORD=secure-generated-password

CACHE_DRIVER=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis

REDIS_HOST=redis-production.internal

MAIL_MAILER=ses
AWS_DEFAULT_REGION=us-east-1

Deployment Script (General)

#!/bin/bash
set -e

echo "Deploying application..."

# Enter maintenance mode
php artisan down --retry=60

# Pull latest code
git pull origin main

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

# Run migrations
php artisan migrate --force

# Clear and rebuild caches
php artisan optimize:clear
php artisan optimize
php artisan view:cache

# Install and build frontend assets
npm ci
npm run build

# Restart queue workers
php artisan queue:restart

# Exit maintenance mode
php artisan up

echo "Deployment complete."

Implementation Patterns

Laravel Forge

# Forge provisions servers and provides:
# - Automated SSL via Let's Encrypt
# - Queue worker management (Supervisor)
# - Scheduled task (cron) management
# - Database management
# - Deployment scripts triggered by Git push

# Typical Forge deployment script
cd /home/forge/example.com

git pull origin $FORGE_SITE_BRANCH

$FORGE_COMPOSER install --no-dev --no-interaction --prefer-dist --optimize-autoloader

( flock -w 10 9 || exit 1
    echo 'Restarting FPM...'
    sudo -S service $FORGE_PHP_FPM reload
) 9>/tmp/fpmrestart.lock

if [ -f artisan ]; then
    $FORGE_PHP artisan migrate --force
    $FORGE_PHP artisan config:cache
    $FORGE_PHP artisan route:cache
    $FORGE_PHP artisan view:cache
    $FORGE_PHP artisan queue:restart
fi

npm ci
npm run build

Forge Queue Worker Configuration

# Managed via Forge UI -> Server -> Sites -> Queue
# Forge creates Supervisor config:
[program:worker-example-com]
process_name=%(program_name)s_%(process_num)02d
command=php /home/forge/example.com/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=forge
numprocs=2
redirect_stderr=true
stdout_logfile=/home/forge/.forge/worker-example-com.log
stopwaitsecs=3600

Laravel Vapor (Serverless)

# vapor.yml
id: 12345
name: my-app

environments:
  production:
    memory: 1024
    cli-memory: 512
    runtime: php-8.3:al2
    build:
      - 'COMPOSER_MIRROR_PATH_REPOS=1 composer install --no-dev'
      - 'php artisan event:cache'
      - 'npm ci && npm run build && rm -rf node_modules'
    deploy:
      - 'php artisan migrate --force'
    storage: my-app-production
    database: my-app-db
    cache: my-app-cache
    gateway: http
    domain: example.com
    queues:
      - default
      - emails

  staging:
    memory: 512
    runtime: php-8.3:al2
    build:
      - 'COMPOSER_MIRROR_PATH_REPOS=1 composer install --no-dev'
      - 'npm ci && npm run build && rm -rf node_modules'
    deploy:
      - 'php artisan migrate --force'
    database: my-app-staging-db
    cache: my-app-staging-cache
    domain: staging.example.com
# Deploy with Vapor CLI
vendor/bin/vapor deploy production
vendor/bin/vapor deploy staging

# Rollback
vendor/bin/vapor rollback production

# Run artisan commands on Lambda
vendor/bin/vapor command production --command="artisan tinker"

# View logs
vendor/bin/vapor tail production

Zero-Downtime Deployment with Envoyer

// Envoyer provides:
// - Symlink-based deployments (current -> releases/20240101120000)
// - Health checks before activating new release
// - One-click rollback
// - Deployment hooks (before/after each phase)

// Directory structure on server:
// /home/forge/example.com/
//   current -> releases/20240315_143022
//   releases/
//     20240315_143022/
//     20240314_120000/
//   storage/  (shared, symlinked into each release)
//   .env      (shared, symlinked into each release)

Health Check Endpoint

// routes/web.php
Route::get('/health', function () {
    try {
        DB::connection()->getPdo();
        Cache::store()->get('health-check');
        // Add more checks as needed

        return response()->json([
            'status'  => 'healthy',
            'time'    => now()->toIso8601String(),
        ]);
    } catch (\Throwable $e) {
        return response()->json([
            'status' => 'unhealthy',
            'error'  => $e->getMessage(),
        ], 503);
    }
});

Database Migration Safety

// For zero-downtime deploys, migrations must be backward-compatible.
// Bad: renaming a column while old code still reads the old name.
// Good: add new column, deploy code that writes to both, migrate data, then remove old column.

// Safe column addition
return new class extends Migration {
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('display_name')->nullable()->after('name');
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn('display_name');
        });
    }
};

Scheduled Tasks in Production

# crontab (Forge manages this automatically)
* * * * * cd /home/forge/example.com && php artisan schedule:run >> /dev/null 2>&1

# Vapor handles scheduling via CloudWatch Events
# No cron setup needed for Vapor deployments

Logging and Monitoring

// config/logging.php - production stack
'channels' => [
    'stack' => [
        'driver'   => 'stack',
        'channels' => ['daily', 'slack'],
        'ignore_exceptions' => false,
    ],

    'daily' => [
        'driver' => 'daily',
        'path'   => storage_path('logs/laravel.log'),
        'level'  => 'warning',
        'days'   => 14,
    ],

    'slack' => [
        'driver'   => 'slack',
        'url'      => env('LOG_SLACK_WEBHOOK_URL'),
        'username' => 'Laravel Log',
        'level'    => 'critical',
    ],
],

Best Practices

  • Never set APP_DEBUG=true in production; it exposes environment variables and stack traces.
  • Run php artisan optimize on every deploy to cache config, routes, and views.
  • Use --no-dev with composer install in production to exclude development dependencies.
  • Set up health check endpoints for load balancers and uptime monitors.
  • Use queue worker process managers (Supervisor on Forge, native on Vapor) rather than running queue:work manually.
  • Store sessions and cache in Redis for horizontal scalability across multiple servers.
  • Use php artisan down --secret="bypass-token" during maintenance to allow testing before going live.
  • Automate deployments via CI/CD (GitHub Actions, GitLab CI) rather than manual SSH.

Common Pitfalls

  • Forgetting php artisan queue:restart: After deployment, old worker processes still run the old code. They must be gracefully restarted.
  • Running migrate without --force: In production, Laravel prompts for confirmation interactively. --force is required for non-interactive deploys.
  • Not caching config: Every call to config() reads from disk without config:cache. This is a significant performance penalty.
  • Deploying with APP_ENV=local: Accidentally deploying local config exposes debug info and may use wrong database/mail credentials.
  • Ignoring disk space for logs: Without log rotation (daily driver or logrotate), logs can fill the disk.
  • Breaking migrations in zero-downtime deploys: Column renames or drops break the old code still running on other servers. Use a multi-step migration approach.
  • Missing .env on fresh servers: The deploy script assumes .env exists. Forge and Envoyer manage this, but manual setups often forget.

Anti-Patterns

  • The SSH-and-pray deploy — manually SSH-ing into the production server, running git pull, and hoping nothing breaks. Manual deployments are unrepeatable, unauditable, and prone to forgotten steps. Every deploy should be triggered by a script or CI/CD pipeline, never by a human typing commands on a production server.

  • Destructive migrations in zero-downtime deploys — renaming or dropping columns while the old code is still serving traffic on other servers. The old code reads from the old column name and crashes. Use a multi-step migration approach: add the new column, deploy code that writes to both, migrate data, then remove the old column in a subsequent release.

  • Debug mode in production — deploying with APP_DEBUG=true because it "helps see errors." Debug mode exposes environment variables, database credentials, and full stack traces to anyone who triggers an error. Set APP_DEBUG=false and use structured logging and error tracking services (Sentry, Flare) instead.

  • Skipping cache warmup — deploying without running php artisan optimize, causing every request to re-parse config files, re-register routes, and re-compile Blade templates. The first users after a deploy experience significantly slower responses. Cache warmup should be a mandatory step in every deployment script.

  • Queue workers running old code — deploying new code but forgetting php artisan queue:restart, leaving workers processing jobs with the previous version's logic. This causes subtle bugs where some jobs use new code and others use old code. Always restart queue workers after every deploy.

Install this skill directly: skilldb add php-laravel-skills

Get CLI access →