How to Set Up CI/CD for Laravel on GitHub Actions

We earn commissions when you shop through the links below.

If you’ve been deploying Laravel apps by SSH-ing into a server and running git pull manually, this guide is for you. Knowing how to set up CI/CD for Laravel on GitHub Actions is one of those skills that pays dividends immediately — you get automated testing, consistent deployments, and way less room for human error. I’ve set this up across several production Laravel apps and I’ll walk you through exactly what works.

Why GitHub Actions for Laravel?

GitHub Actions is free for public repos and has a generous free tier for private ones. More importantly, it lives right next to your code. No third-party CI service to authenticate, no webhook configuration to debug. You push a commit, the pipeline runs. It’s that direct.

The workflow I’ll show you covers:

  • Running PHPUnit tests with a real MySQL database
  • Linting with Pint or PHP CS Fixer
  • Deploying to a VPS on merge to main

Project Prerequisites

You’ll need:

  • A Laravel app hosted on GitHub
  • A server running PHP 8.x — I use DigitalOcean droplets for most of my Laravel projects, they’re reliable and cheap for this kind of work
  • SSH access to that server
  • Composer installed on the server

Step 1: Create the Workflow File

GitHub Actions workflows live in .github/workflows/. Create a file called ci.yml in that directory. Here’s a complete workflow that runs tests on every push and deploys on merges to main:

name: Laravel CI/CD

on:
  push:
    branches: [ "*" ]
  pull_request:
    branches: [ "main" ]

jobs:
  test:
    runs-on: ubuntu-latest

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

    steps:
      - uses: actions/checkout@v4

      - name: Set up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: mbstring, bcmath, pdo_mysql
          coverage: none

      - name: Copy .env
        run: cp .env.example .env.testing

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

      - name: Generate app key
        run: php artisan key:generate --env=testing

      - name: Run migrations
        env:
          DB_CONNECTION: mysql
          DB_HOST: 127.0.0.1
          DB_PORT: 3306
          DB_DATABASE: laravel_test
          DB_USERNAME: root
          DB_PASSWORD: root
        run: php artisan migrate --env=testing --force

      - name: Run tests
        env:
          DB_CONNECTION: mysql
          DB_HOST: 127.0.0.1
          DB_PORT: 3306
          DB_DATABASE: laravel_test
          DB_USERNAME: root
          DB_PASSWORD: root
        run: php artisan test --parallel

  deploy:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'

    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: |
            cd /var/www/your-app
            git pull origin main
            composer install --no-dev --optimize-autoloader
            php artisan migrate --force
            php artisan config:cache
            php artisan route:cache
            php artisan view:cache
            php artisan queue:restart

This is a real, production-ready workflow. Let me break down the important parts.

Step 2: Configure GitHub Secrets

The deploy job references three secrets. Go to your repo on GitHub, then Settings → Secrets and variables → Actions and add:

  • SERVER_HOST — your server’s IP or domain
  • SERVER_USER — the SSH user (e.g., deployer or www-data)
  • SERVER_SSH_KEY — the private SSH key (paste the full contents of your ~/.ssh/id_rsa)

For the SSH key, generate a dedicated deploy key on your server:

ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/deploy_key
cat ~/.ssh/deploy_key.pub >> ~/.ssh/authorized_keys
cat ~/.ssh/deploy_key  # paste this into SERVER_SSH_KEY secret

Never commit SSH keys or any credentials to your repo. The secrets vault in GitHub Actions is the right place for all of this.

Step 3: Prepare Your Server

Before the first automated deploy, your server needs a working checkout of the repo. SSH in and clone it manually once:

cd /var/www
git clone git@github.com:your-org/your-app.git your-app
cd your-app
cp .env.example .env
# Fill in .env with production values
php artisan key:generate
php artisan migrate

After this initial setup, every push to main will trigger the automated deploy job — no more manual SSH sessions.

Step 4: Add Code Quality Checks (Optional but Recommended)

I usually add a linting step before the test job runs. If you’re using Laravel Pint:

  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'

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

      - name: Run Pint
        run: ./vendor/bin/pint --test

Then update the test job to needs: lint so linting always runs first. The pipeline fails fast on style issues before wasting time running migrations and tests.

Step 5: Caching Composer Dependencies

If your pipeline feels slow, add Composer caching. This cuts install time significantly on subsequent runs:

      - name: Get Composer cache directory
        id: composer-cache
        run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT

      - name: Cache Composer packages
        uses: actions/cache@v4
        with:
          path: ${{ steps.composer-cache.outputs.dir }}
          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
          restore-keys: |
            ${{ runner.os }}-composer-

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

Insert these steps before the Install dependencies step. The cache key is based on composer.lock, so it invalidates automatically when you add or update packages.

Deployment Alternatives

If you don’t want to manage your own VPS, Railway is worth considering for Laravel deployments — it supports PHP natively, handles environment variables cleanly, and deploys automatically from GitHub without you needing to manage SSH keys or server config at all. You’d replace the deploy job with a simple Railway CLI call or just let Railway’s GitHub integration handle it.

Common Issues and Fixes

MySQL connection refused: The health check in the service definition handles this, but make sure you’re connecting to 127.0.0.1 not localhost. PHP’s MySQL driver treats them differently.

Permission errors on deploy: Make sure the SSH user owns the app directory and has write permissions. Running deploys as root works but isn’t best practice — create a dedicated deployer user with access only to the web directory.

App downtime during deploy: The basic script above has a brief window where the app could serve errors during migrations. For zero-downtime deploys, look into tools like Deployer or Envoyer, which use an atomic symlink strategy.

Queue workers not restarting: The queue:restart command signals workers to restart gracefully after they finish their current job. Make sure you’re running Supervisor or a similar process manager — otherwise queue workers keep running old code.

Wrapping Up

That’s the full picture of how to set up CI/CD for Laravel on GitHub Actions. You’ve got automated testing with a real MySQL service, code quality checks, Composer caching, and SSH-based deployment to a VPS — all triggered automatically on every push.

The workflow file I’ve shown here is close to what I run in production. Start with it as-is, then layer in additional steps as your project grows: static analysis with Larastan, browser tests with Dusk, or deployment notifications via Slack.

If you want to go deeper on Laravel architecture and DevOps patterns, Udemy has solid Laravel courses that cover testing and deployment workflows in much more depth than a single blog post can.

Once you’ve done this once, you’ll never want to go back to manual deployments. The feedback loop alone — knowing within two minutes whether your PR breaks anything — is worth the setup time.