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.

Dit artikel biedt een complete GitHub Actions workflow voor Laravel: parallel testen met MySQL en Redis, PHP CS Fixer voor code stijl, PHPStan voor statische analyse, beveiligingsscans en zero-downtime deployment. Kopieer, pas aan, deploy.

Este artículo proporciona un flujo de trabajo completo de GitHub Actions para Laravel: pruebas paralelas con MySQL y Redis, PHP CS Fixer para estilo de código, PHPStan para análisis estático, escaneo de seguridad y despliegue sin tiempo de inactividad. Copia, adapta, despliega.

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.

Waarom GitHub Actions?

Ik heb door de jaren heen Jenkins, GitLab CI, CircleCI en Travis CI gebruikt. GitHub Actions is niet per se de "beste" - maar voor projecten die al op GitHub gehost worden, is het de meest praktische keuze. Strakke integratie, geen extra service om te beheren, en een royale gratis tier voor private repos.

Wat volgt is een workflow die ik heb verfijnd over tientallen Laravel projecten. Het is niet theoretisch - dit draait elke dag in productie en vangt bugs voordat ze gebruikers bereiken.

De complete workflow

Laten we beginnen met het volledige workflow bestand, en vervolgens elke sectie uitleggen:

# .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

Uitleg per onderdeel

Matrix testen

De test job draait op meerdere PHP versies tegelijkertijd:

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

Het instellen van fail-fast: false zorgt ervoor dat alle matrix jobs compleet worden, zelfs als een job faalt. Dit is nuttig om het complete plaatje te zien - misschien werkt je code op 8.3 maar breekt het op 8.2.

Service containers

GitHub Actions maakt het eenvoudig om MySQL en Redis op te starten als service containers:

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

De health check zorgt ervoor dat MySQL klaar is voordat tests beginnen. Zonder deze check zie je intermittent "connection refused" fouten.

Dependency caching

Composer installatie is een van de traagste stappen. Caching vermindert dit van 2 minuten naar seconden:

- 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-

De cache sleutel bevat de composer.lock hash, dus de cache wordt automatisch ongeldig gemaakt wanneer dependencies veranderen.

Parallel testen

Laravel's parallel testing feature kan je test suite dramatisch versnellen:

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

Op een test suite met 500+ tests vermindert parallel testen de runtime typisch met 60-70%.

Het environment bestand

Maak een dedicated CI environment bestand aan op .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

Belangrijke instellingen:

  • LOG_CHANNEL=stderr - Logs verschijnen in GitHub Actions output
  • QUEUE_CONNECTION=sync - Jobs worden direct uitgevoerd in tests
  • SESSION_DRIVER=array - Geen session opslag nodig

PHP CS Fixer configuratie

Maak .php-cs-fixer.php aan in je 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 configuratie

Maak phpstan.neon aan:

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

Begin met level 5 of 6. Direct naar level 9 gaan op een bestaand project genereert duizenden fouten.

Larastan

Installeer Larastan (composer require --dev larastan/larastan) voor Laravel-specifieke PHPStan regels. Het begrijpt Eloquent, facades en andere Laravel patronen die anders PHPStan zouden verwarren.

Zero-downtime deployment

Het deployment script in de workflow gebruikt php artisan down voor eenvoud. Voor echte zero-downtime deployments, overweeg deze alternatieve aanpak:

#!/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 configuratie

Configureer deze secrets in je GitHub repository instellingen:

  • PRODUCTION_HOST - Het IP-adres of hostname van je server
  • PRODUCTION_USER - SSH gebruikersnaam
  • PRODUCTION_SSH_KEY - Private SSH key (zonder wachtwoordzin)

Genereer een dedicated deployment key:

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

Voeg de public key toe aan ~/.ssh/authorized_keys op je server, en de private key als GitHub secret.

Workflow optimalisatie tips

1. Voer jobs parallel uit

De code-style, static-analysis en security jobs zijn niet afhankelijk van elkaar. Ze draaien automatisch parallel, wat de totale CI tijd vermindert.

2. Sla onnodige runs over

Voeg path filters toe om CI over te slaan voor documentatie-only wijzigingen:

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

3. Gebruik concurrency controls

Voorkom dat meerdere deployments gelijktijdig draaien:

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

Wat dit vangt

Met deze workflow worden bij elke pull request automatisch de volgende zaken gecontroleerd:

  • Test failures over PHP versies
  • Code stijl overtredingen
  • Type fouten en potentiële bugs (PHPStan)
  • Bekende beveiligingsproblemen in dependencies

Problemen worden gevangen voordat code review zelfs maar begint. Reviewers kunnen zich richten op architectuur en logica in plaats van het opsporen van typefouten en stijlproblemen.

¿Por qué GitHub Actions?

He usado Jenkins, GitLab CI, CircleCI y Travis CI a lo largo de los años. GitHub Actions no es necesariamente el "mejor", pero para proyectos ya alojados en GitHub, es la opción más práctica. Integración estrecha, sin servicios adicionales que gestionar y un generoso nivel gratuito para repos privados.

Lo que sigue es un flujo de trabajo que he refinado en docenas de proyectos Laravel. No es teórico: esto se ejecuta en producción todos los días, detectando errores antes de que lleguen a los usuarios.

El flujo de trabajo completo

Comencemos con el archivo de flujo de trabajo completo, luego desglosaremos cada sección:

# .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

Desglose

Pruebas matriciales

El trabajo de prueba se ejecuta en múltiples versiones de PHP simultáneamente:

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

Configurar fail-fast: false asegura que todos los trabajos de matriz se completen incluso si uno falla. Esto es útil para ver el panorama completo: tal vez tu código funcione en 8.3 pero falle en 8.2.

Contenedores de servicios

GitHub Actions facilita iniciar MySQL y Redis como contenedores de servicios:

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

La verificación de salud asegura que MySQL esté listo antes de que se ejecuten las pruebas. Sin ella, verás fallos intermitentes de "conexión rechazada".

Caché de dependencias

La instalación de Composer es uno de los pasos más lentos. El caché reduce esto de 2 minutos a segundos:

- 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-

La clave del caché incluye el hash de composer.lock, por lo que el caché se invalida automáticamente cuando cambian las dependencias.

Pruebas paralelas

La función de pruebas paralelas de Laravel puede acelerar dramáticamente tu suite de pruebas:

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

En una suite con más de 500 pruebas, las pruebas paralelas típicamente reducen el tiempo de ejecución en un 60-70%.

El archivo de entorno

Crea un archivo de entorno CI dedicado en .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

Configuraciones clave:

  • LOG_CHANNEL=stderr - Los logs aparecen en la salida de GitHub Actions
  • QUEUE_CONNECTION=sync - Los trabajos se ejecutan inmediatamente en las pruebas
  • SESSION_DRIVER=array - No se necesita almacenamiento de sesión

Configuración de PHP CS Fixer

Crea .php-cs-fixer.php en la raíz de tu proyecto:

<?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);

Configuración de PHPStan

Crea 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

Comienza con el nivel 5 o 6. Ir directamente al nivel 9 en un proyecto existente generará miles de errores.

Larastan

Instala Larastan (composer require --dev larastan/larastan) para reglas PHPStan específicas de Laravel. Entiende Eloquent, facades y otros patrones de Laravel que de otro modo confundirían a PHPStan.

Despliegue sin tiempo de inactividad

El script de despliegue en el flujo de trabajo usa php artisan down por simplicidad. Para despliegues realmente sin tiempo de inactividad, considera este enfoque alternativo:

#!/bin/bash
# deploy.sh - Script de despliegue sin tiempo de inactividad

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!"

Configuración de secretos

Configura estos secretos en la configuración de tu repositorio de GitHub:

  • PRODUCTION_HOST - La IP o nombre de host de tu servidor
  • PRODUCTION_USER - Nombre de usuario SSH
  • PRODUCTION_SSH_KEY - Clave SSH privada (sin frase de contraseña)

Genera una clave de despliegue dedicada:

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

Añade la clave pública a ~/.ssh/authorized_keys en tu servidor, y la clave privada como un secreto de GitHub.

Consejos de optimización del flujo de trabajo

1. Ejecuta trabajos en paralelo

Los trabajos code-style, static-analysis y security no dependen entre sí. Se ejecutan en paralelo automáticamente, reduciendo el tiempo total de CI.

2. Omite ejecuciones innecesarias

Añade filtros de ruta para omitir CI en cambios solo de documentación:

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

3. Usa controles de concurrencia

Evita que múltiples despliegues se ejecuten simultáneamente:

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

Lo que esto detecta

Con este flujo de trabajo implementado, cada pull request se verifica automáticamente para:

  • Fallos de pruebas en diferentes versiones de PHP
  • Violaciones de estilo de código
  • Errores de tipo y bugs potenciales (PHPStan)
  • Vulnerabilidades de seguridad conocidas en dependencias

Los problemas se detectan antes de que comience la revisión de código. Los revisores pueden centrarse en la arquitectura y la lógica en lugar de detectar errores tipográficos y problemas de estilo.

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