Back to blog
DevOps 15 min read

GitHub Actions for Laravel: A Practical Setup

A battle-tested CI/CD workflow that's been running in production for years. Testing, linting, security scanning, and deployment - all automated.

October 2024 · Updated October 2024

TL;DR

This article provides a complete GitHub Actions workflow for Laravel: parallel testing with MySQL and Redis, PHP CS Fixer for code style, PHPStan for static analysis, security scanning, and zero-downtime deployment. Copy, adapt, deploy.

Why GitHub Actions?

I've used Jenkins, GitLab CI, CircleCI, and Travis CI over the years. GitHub Actions isn't necessarily the "best" - but for projects already hosted on GitHub, it's the most practical choice. Tight integration, no additional service to manage, and generous free tier for private repos.

What follows is a workflow I've refined over dozens of Laravel projects. It's not theoretical - this runs in production every day, catching bugs before they reach users.

The complete workflow

Let's start with the full workflow file, then break down each section:

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

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

jobs:
  tests:
    name: Tests (PHP ${{ matrix.php }})
    runs-on: ubuntu-latest

    strategy:
      fail-fast: false
      matrix:
        php: ['8.2', '8.3']

    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: password
          MYSQL_DATABASE: testing
        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 --health-timeout=5s --health-retries=3

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_mysql, bcmath, redis
          coverage: xdebug

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

      - name: Cache Composer dependencies
        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 --prefer-dist --no-interaction --no-progress

      - name: Copy environment file
        run: cp .env.ci .env

      - name: Generate application key
        run: php artisan key:generate

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

      - name: Run tests
        run: php artisan test --parallel --coverage-clover coverage.xml
        env:
          DB_CONNECTION: mysql
          DB_HOST: 127.0.0.1
          DB_PORT: 3306
          DB_DATABASE: testing
          DB_USERNAME: root
          DB_PASSWORD: password
          REDIS_HOST: 127.0.0.1
          REDIS_PORT: 6379

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        with:
          files: coverage.xml
          fail_ci_if_error: false

  code-style:
    name: Code Style
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          tools: php-cs-fixer

      - name: Run PHP CS Fixer
        run: php-cs-fixer fix --dry-run --diff --config=.php-cs-fixer.php

  static-analysis:
    name: Static Analysis
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

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

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

      - name: Cache Composer dependencies
        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 --prefer-dist --no-interaction --no-progress

      - name: Run PHPStan
        run: vendor/bin/phpstan analyse --memory-limit=2G

  security:
    name: Security Scan
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Security check for PHP dependencies
        uses: symfonycorp/security-checker-action@v5

  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    needs: [tests, code-style, static-analysis, security]
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'

    steps:
      - uses: actions/checkout@v4

      - name: Deploy to production
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.PRODUCTION_HOST }}
          username: ${{ secrets.PRODUCTION_USER }}
          key: ${{ secrets.PRODUCTION_SSH_KEY }}
          script: |
            cd /var/www/myapp
            php artisan down --retry=60
            git pull origin main
            composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader
            php artisan migrate --force
            php artisan config:cache
            php artisan route:cache
            php artisan view:cache
            php artisan queue:restart
            php artisan up

Breaking it down

Matrix testing

The test job runs on multiple PHP versions simultaneously:

strategy:
  fail-fast: false
  matrix:
    php: ['8.2', '8.3']

Setting fail-fast: false ensures all matrix jobs complete even if one fails. This is useful for seeing the full picture - maybe your code works on 8.3 but breaks on 8.2.

Service containers

GitHub Actions makes it easy to spin up MySQL and Redis as service containers:

services:
  mysql:
    image: mysql:8.0
    env:
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: testing
    options: --health-cmd="mysqladmin ping" --health-interval=10s

The health check ensures MySQL is ready before tests run. Without it, you'll see intermittent "connection refused" failures.

Dependency caching

Composer installation is one of the slowest steps. Caching cuts this from 2 minutes to seconds:

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

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

The cache key includes the composer.lock hash, so the cache invalidates automatically when dependencies change.

Parallel testing

Laravel's parallel testing feature can dramatically speed up your test suite:

- name: Run tests
  run: php artisan test --parallel --coverage-clover coverage.xml

On a suite with 500+ tests, parallel testing typically reduces runtime by 60-70%.

The environment file

Create a dedicated CI environment file at .env.ci:

# .env.ci
APP_NAME=Laravel
APP_ENV=testing
APP_DEBUG=true

LOG_CHANNEL=stderr

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=testing
DB_USERNAME=root
DB_PASSWORD=password

CACHE_DRIVER=redis
QUEUE_CONNECTION=sync
SESSION_DRIVER=array

REDIS_HOST=127.0.0.1
REDIS_PORT=6379

Key settings:

  • LOG_CHANNEL=stderr - Logs appear in GitHub Actions output
  • QUEUE_CONNECTION=sync - Jobs run immediately in tests
  • SESSION_DRIVER=array - No session storage needed

PHP CS Fixer Configuration

Create .php-cs-fixer.php in your project root:

<?php

$finder = PhpCsFixer\Finder::create()
    ->in([
        __DIR__ . '/app',
        __DIR__ . '/config',
        __DIR__ . '/database',
        __DIR__ . '/routes',
        __DIR__ . '/tests',
    ])
    ->name('*.php')
    ->notName('*.blade.php')
    ->ignoreDotFiles(true)
    ->ignoreVCS(true);

return (new PhpCsFixer\Config())
    ->setRules([
        '@PSR12' => true,
        'array_syntax' => ['syntax' => 'short'],
        'ordered_imports' => ['sort_algorithm' => 'alpha'],
        'no_unused_imports' => true,
        'not_operator_with_successor_space' => true,
        'trailing_comma_in_multiline' => true,
        'phpdoc_scalar' => true,
        'unary_operator_spaces' => true,
        'binary_operator_spaces' => true,
        'blank_line_before_statement' => [
            'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'],
        ],
        'phpdoc_single_line_var_spacing' => true,
        'phpdoc_var_without_name' => true,
        'class_attributes_separation' => [
            'elements' => [
                'method' => 'one',
            ],
        ],
        'method_argument_space' => [
            'on_multiline' => 'ensure_fully_multiline',
            'keep_multiple_spaces_after_comma' => true,
        ],
        'single_trait_insert_per_statement' => true,
    ])
    ->setFinder($finder);

PHPStan Configuration

Create phpstan.neon:

includes:
    - ./vendor/larastan/larastan/extension.neon

parameters:
    paths:
        - app
        - config
        - database
        - routes

    level: 6

    ignoreErrors:
        # Add patterns for known false positives

    excludePaths:
        - app/Console/Kernel.php

    checkMissingIterableValueType: false

Start with level 5 or 6. Going straight to level 9 on an existing project will generate thousands of errors.

Larastan

Install Larastan (composer require --dev larastan/larastan) for Laravel-specific PHPStan rules. It understands Eloquent, facades, and other Laravel patterns that would otherwise confuse PHPStan.

Zero-downtime deployment

The deployment script in the workflow uses php artisan down for simplicity. For true zero-downtime deployments, consider this alternative approach:

#!/bin/bash
# deploy.sh - Zero-downtime deployment script

set -e

DEPLOY_PATH="/var/www/myapp"
RELEASES_PATH="$DEPLOY_PATH/releases"
SHARED_PATH="$DEPLOY_PATH/shared"
CURRENT_PATH="$DEPLOY_PATH/current"
RELEASE=$(date +%Y%m%d%H%M%S)

echo "Creating release directory..."
mkdir -p "$RELEASES_PATH/$RELEASE"

echo "Cloning repository..."
git clone --depth 1 git@github.com:user/repo.git "$RELEASES_PATH/$RELEASE"

echo "Linking shared files..."
ln -nfs "$SHARED_PATH/.env" "$RELEASES_PATH/$RELEASE/.env"
ln -nfs "$SHARED_PATH/storage" "$RELEASES_PATH/$RELEASE/storage"

echo "Installing dependencies..."
cd "$RELEASES_PATH/$RELEASE"
composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader

echo "Running migrations..."
php artisan migrate --force

echo "Caching configuration..."
php artisan config:cache
php artisan route:cache
php artisan view:cache

echo "Switching to new release..."
ln -nfs "$RELEASES_PATH/$RELEASE" "$CURRENT_PATH"

echo "Restarting queue workers..."
php artisan queue:restart

echo "Cleaning old releases..."
ls -1dt "$RELEASES_PATH"/* | tail -n +6 | xargs rm -rf

echo "Deployment complete!"

Secrets configuration

Configure these secrets in your GitHub repository settings:

  • PRODUCTION_HOST - Your server's IP or hostname
  • PRODUCTION_USER - SSH username
  • PRODUCTION_SSH_KEY - Private SSH key (without passphrase)

Generate a dedicated deployment key:

ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key -N ""

Add the public key to ~/.ssh/authorized_keys on your server, and the private key as a GitHub secret.

Workflow optimisation tips

1. Run jobs in parallel

The code-style, static-analysis, and security jobs don't depend on each other. They run in parallel automatically, reducing total CI time.

2. Skip unnecessary runs

Add path filters to skip CI for documentation-only changes:

on:
  push:
    branches: [main]
    paths-ignore:
      - '**.md'
      - 'docs/**'

3. Use concurrency controls

Prevent multiple deployments from running simultaneously:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

What this catches

With this workflow in place, every pull request is automatically checked for:

  • Test failures across PHP versions
  • Code style violations
  • Type errors and potential bugs (PHPStan)
  • Known security vulnerabilities in dependencies

Problems are caught before code review even begins. Reviewers can focus on architecture and logic instead of spotting typos and style issues.

Need help setting up CI/CD for your Laravel project? I've implemented these workflows for teams of all sizes. Let's talk.

Related posts

Need help automating your deployment pipeline?

From simple CI/CD to complex multi-environment deployments, I can help streamline your workflow.

Get in touch