laravel 7 min read

Laravel CI/CD with GitHub Actions and Laravel Forge: Complete Guide

Automate your Laravel deployments end-to-end — from a git push to a zero-downtime production deploy — using GitHub Actions for CI and Laravel Forge for CD.

G
Gurpreet Singh
March 01, 2026

Why Automated Deployments Matter

Manual deployments are one of the highest-risk activities in software development. SSH into the server, run git pull, run composer install, run migrations, restart queues — in the right order, without forgetting a step, while hoping nothing breaks in production while you are doing it. One missed step or one typo and you have an outage.

I upgraded a client from manual SSH deployments to a fully automated GitHub Actions + Laravel Forge pipeline last year. Their previous deployment process took 45 minutes and caused an average of one partial outage per month. After automation: deployments take 4 minutes, run on every merge to main, and they have had zero deployment-related outages in 8 months. The pipeline pays for itself on the first prevented incident.

This guide walks through a production-grade Laravel CI/CD pipeline using GitHub Actions (for testing and building) and Laravel Forge (for zero-downtime deployment).

Architecture Overview

The pipeline has two stages:

  1. CI (Continuous Integration) — GitHub Actions: Run on every push and pull request. Installs dependencies, runs the test suite, checks code style, builds frontend assets.
  2. CD (Continuous Deployment) — Laravel Forge: Runs only on pushes to main. Triggers a Forge deploy script that pulls the latest code, runs migrations, clears caches, and restarts queue workers — all with zero downtime.

Step 1: Set Up the GitHub Actions CI Workflow

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: password
          MYSQL_DATABASE: laravel_test
        ports: [3306:3306]
        options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3

      redis:
        image: redis:7
        ports: [6379:6379]
        options: --health-cmd="redis-cli ping" --health-interval=10s

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up PHP 8.4
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          extensions: mbstring, pdo, pdo_mysql, redis, pcov
          coverage: pcov

      - name: Cache Composer dependencies
        uses: actions/cache@v4
        with:
          path: vendor
          key: composer-${{ hashFiles('composer.lock') }}

      - name: Install Composer dependencies
        run: composer install --no-interaction --prefer-dist --optimize-autoloader

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install and build frontend assets
        run: npm ci && npm run build

      - name: Prepare Laravel environment
        run: |
          cp .env.ci .env
          php artisan key:generate
          php artisan migrate --force

      - name: Run test suite with coverage
        run: php artisan test --coverage --min=70

      - name: Check code style (Pint)
        run: ./vendor/bin/pint --test

      - name: Run static analysis (PHPStan)
        run: ./vendor/bin/phpstan analyse --level=6

The CI Environment File

# .env.ci
APP_ENV=testing
APP_KEY=
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel_test
DB_USERNAME=root
DB_PASSWORD=password
CACHE_DRIVER=redis
QUEUE_CONNECTION=sync
REDIS_HOST=127.0.0.1
MAIL_MAILER=array
STRIPE_KEY=sk_test_placeholder
OPENAI_API_KEY=sk-placeholder

The QUEUE_CONNECTION=sync ensures queued jobs run synchronously during tests — no need for a separate queue worker in CI. Third-party API keys are placeholders since your tests should mock external calls.

Step 2: Set Up Laravel Forge for Zero-Downtime Deployment

Configure the Forge Deploy Script

In your Forge server dashboard, replace the default deploy script with this zero-downtime version:

cd /home/forge/yourapp.com

# Pull latest code
git pull origin main

# Install PHP dependencies (no dev, optimised autoloader)
composer install --no-interaction --prefer-dist --optimize-autoloader --no-dev

# Install and build frontend assets
npm ci --prefer-offline
npm run build

# Run database migrations (with --force for production)
php artisan migrate --force

# Clear and rebuild all caches
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache

# Restart queue workers gracefully
# (finishes current jobs before restarting — no dropped jobs)
php artisan queue:restart

# Reload PHP-FPM gracefully (zero downtime)
echo "" | sudo -S service php8.4-fpm reload

The key to zero-downtime here is queue:restart (which signals workers to stop after their current job) and php-fpm reload (which keeps serving existing requests while reloading configuration). New requests are served by the reloaded workers; in-flight requests complete on the old workers.

Step 3: Connect GitHub Actions to Forge Deployment

Forge provides a deployment webhook URL per site. Add the CD job to your GitHub Actions workflow:

# .github/workflows/deploy.yml
name: Deploy to Production

on:
  push:
    branches: [main]

jobs:
  # Only deploy if CI passes
  deploy:
    needs: [test]  # references the test job from ci.yml
    runs-on: ubuntu-latest
    environment: production  # requires approval if configured

    steps:
      - name: Trigger Laravel Forge deployment
        run: |
          curl -s -X POST "${{ secrets.FORGE_DEPLOY_WEBHOOK }}" \
            -H "Content-Type: application/json" \
            --fail-with-body
          echo "Deployment triggered successfully"

Store your Forge webhook URL as a GitHub Actions secret named FORGE_DEPLOY_WEBHOOK. The needs: [test] dependency ensures deployment only runs if all CI checks pass. The --fail-with-body flag makes curl exit with a non-zero code if Forge returns an error, failing the workflow and alerting you.

Step 4: Handling Database Migrations Safely

The most dangerous part of any deployment is a migration that breaks the running application. These practices keep migrations safe:

Never Drop Columns or Rename Without a Two-Phase Migration

// UNSAFE — if deployment fails mid-way, old code hits new schema
// and crashes on the missing column
Schema::table('users', function (Blueprint $table) {
    $table->renameColumn('name', 'full_name');
});

// SAFE — Phase 1: add new column (backward compatible)
Schema::table('users', function (Blueprint $table) {
    $table->string('full_name')->nullable()->after('name');
});

// (deploy + data backfill here)

// SAFE — Phase 2 (next deploy): remove old column
Schema::table('users', function (Blueprint $table) {
    $table->dropColumn('name');
});

Keep Migrations Idempotent

// Bad — throws if column already exists (e.g., after a partial deploy)
$table->string('api_token');

// Good — safe to run multiple times
if (!Schema::hasColumn('users', 'api_token')) {
    $table->string('api_token');
}

Step 5: Environment-Specific Configuration with GitHub Secrets

Never commit .env.production to git. Manage production environment variables through Forge's environment editor, and use GitHub Secrets only for values your Actions workflow needs (like the deploy webhook URL and any API keys needed during the build step).

For values that need to be available at build time (e.g., VITE_APP_URL for frontend asset compilation), add them as GitHub Actions secrets and pass them to the build step:

- name: Build frontend assets
  env:
    VITE_APP_URL: ${{ secrets.VITE_APP_URL }}
    VITE_PUSHER_APP_KEY: ${{ secrets.VITE_PUSHER_APP_KEY }}
  run: npm ci && npm run build

Monitoring Your Pipeline

A deployment pipeline you cannot observe is a deployment pipeline you cannot trust. Add these monitoring layers:

  • GitHub Actions notifications: Configure Slack notifications via the 8398a7/action-slack action — get alerted on CI failures and deployment success/failure.
  • Forge deployment log: Every Forge deployment records stdout/stderr output. Review this log after every deploy for the first month to build familiarity with what a healthy deploy looks like.
  • Laravel Telescope or Pulse: Monitor exceptions, slow queries, and queue job failures in the first 30 minutes after each deployment.
  • Health check endpoint: Add a /health route that checks database connectivity, Redis connectivity, and queue worker status. Ping it after every deployment to confirm the app is healthy before closing the deployment.

Frequently Asked Questions

How do I handle long-running migrations without downtime?

For migrations on large tables (millions of rows), standard ALTER TABLE operations can lock the table for minutes and cause downtime. The solution is to use a tool like gh-ost or pt-online-schema-change for MySQL — or to build the migration as a background job that backfills data in small batches while the application continues running. For PostgreSQL, most ALTER TABLE operations (adding a nullable column, adding an index with CONCURRENTLY) are non-blocking and safe to run during deployment.

Can I use this pipeline with a VPS instead of Laravel Forge?

Yes. Replace the Forge webhook step with a direct SSH action using appleboy/ssh-action. Store your server's SSH private key as a GitHub Secret and run the same deploy script directly over SSH. Forge is worth the cost ($19/month per server) because it manages PHP-FPM, Nginx configuration, SSL certificates, and database provisioning — but the CI portion of this guide works identically regardless of your deployment target.

What is the minimum test coverage I should require in CI?

For a production Laravel application, enforce a minimum of 70% line coverage on the critical paths — authentication, payment processing, and your core business logic. Do not obsess over 100% coverage; the diminishing returns past 80% are real. More valuable than coverage percentage is ensuring your highest-risk code (billing, data deletion, multi-tenant isolation) has dedicated integration tests that run against a real database.

#Laravel #DevOps #CI/CD #GitHub Actions #Laravel Forge #Docker #Deployment
G
Gurpreet Singh

Senior Full Stack Developer — Laravel, Vue.js, Nuxt.js & AI. Available for freelance projects.

Hire Me for Your Project

Related Articles