Navigation

Laravel

CI/CD for Laravel: Automating Tests and Deployments with GitHub Actions

Complete Laravel CI/CD guide with GitHub Actions. Covers automated testing, code quality checks, deployments to shared hosting/VPS/Forge, Slack notifications, and production-ready workflows with troubleshooting tips.

Table Of Contents

Introduction

After 15 years of manually deploying applications and dealing with "it works on my machine" syndrome, I can confidently say that implementing CI/CD pipelines is one of the best investments you can make as a Laravel developer.

Continuous Integration and Continuous Deployment (CI/CD) automates your testing and deployment processes, ensuring that every code change is validated before reaching production. This not only reduces bugs but also gives you the confidence to deploy multiple times per day without breaking your application.

In this comprehensive guide, I'll walk you through setting up a robust CI/CD pipeline for Laravel using GitHub Actions. We'll cover everything from basic test automation to deploying across different hosting environments, complete with notifications to keep your team informed.

Understanding Laravel Pipeline Structure

Before diving into implementation, let's understand what a typical Laravel CI/CD pipeline should accomplish:

Pipeline Stages

  1. Code Quality Checks: Static analysis, linting, and code style validation
  2. Dependency Installation: Composer and npm package installation
  3. Database Setup: Creating test databases and running migrations
  4. Test Execution: Unit tests, feature tests, and browser tests
  5. Build Assets: Compiling CSS/JS for production
  6. Deployment: Pushing code to staging/production environments
  7. Notifications: Informing team members about deployment status

Why This Structure Matters

Each stage serves a specific purpose and should fail fast if issues are detected. For example, there's no point in running expensive browser tests if your code doesn't pass basic linting rules. This approach saves both time and compute resources.

Setting Up GitHub Actions

GitHub Actions provides a powerful platform for CI/CD directly integrated with your repository. The workflow files live in .github/workflows/ directory and use YAML syntax.

Basic Workflow Structure

Create .github/workflows/ci.yml in your Laravel project:

name: CI/CD Pipeline

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

jobs:
  tests:
    runs-on: ubuntu-latest
    
    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

    steps:
      - uses: actions/checkout@v4
      
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
          extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
          coverage: xdebug

      - name: Cache Composer packages
        id: composer-cache
        uses: actions/cache@v3
        with:
          path: vendor
          key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
          restore-keys: |
            ${{ runner.os }}-php-

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

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

      - name: Directory Permissions
        run: chmod -R 777 storage bootstrap/cache

      - name: Create Database
        run: |
          mkdir -p database
          touch database/database.sqlite

      - name: Execute tests (Unit and Feature tests) via PHPUnit
        env:
          DB_CONNECTION: sqlite
          DB_DATABASE: database/database.sqlite
        run: vendor/bin/phpunit --coverage-text

Running Tests in CI

Test Configuration

Your Laravel application should have a dedicated testing environment configuration. Update your phpunit.xml:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true"
>
    <testsuites>
        <testsuite name="Unit">
            <directory suffix="Test.php">./tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory suffix="Test.php">./tests/Feature</directory>
        </testsuite>
    </testsuites>
    <coverage>
        <include>
            <directory suffix=".php">./app</directory>
        </include>
    </coverage>
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="BCRYPT_ROUNDS" value="4"/>
        <env name="CACHE_DRIVER" value="array"/>
        <env name="DB_CONNECTION" value="sqlite"/>
        <env name="DB_DATABASE" value=":memory:"/>
        <env name="MAIL_MAILER" value="array"/>
        <env name="QUEUE_CONNECTION" value="sync"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="TELESCOPE_ENABLED" value="false"/>
    </php>
</phpunit>

Advanced Testing Workflow

For larger applications, I recommend splitting tests into separate jobs for faster execution:

name: Advanced CI Pipeline

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

jobs:
  code-quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
          tools: phpstan, phpcs

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

      - name: Run PHPStan
        run: ./vendor/bin/phpstan analyse

      - name: Run PHP CS Fixer
        run: ./vendor/bin/php-cs-fixer fix --dry-run --diff

  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'

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

      - name: Run Unit Tests
        run: ./vendor/bin/phpunit --testsuite=Unit

  feature-tests:
    runs-on: ubuntu-latest
    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

    steps:
      - uses: actions/checkout@v4
      
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'

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

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

      - name: Run Feature Tests
        env:
          DB_CONNECTION: mysql
          DB_HOST: 127.0.0.1
          DB_PORT: 3306
          DB_DATABASE: testing
          DB_USERNAME: root
          DB_PASSWORD: password
        run: |
          php artisan migrate --force
          ./vendor/bin/phpunit --testsuite=Feature

Deployment Strategies

Deploying to Shared Hosting

Shared hosting deployment typically involves FTP/SFTP file transfer. Here's a workflow for cPanel-style hosting:

  deploy-shared:
    runs-on: ubuntu-latest
    needs: [code-quality, unit-tests, feature-tests]
    if: github.ref == 'refs/heads/main'
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'

      - name: Install dependencies
        run: composer install --no-dev --optimize-autoloader

      - name: Build assets
        run: |
          npm ci
          npm run build

      - name: Create deployment archive
        run: |
          tar -czf deploy.tar.gz \
            --exclude='.git' \
            --exclude='.github' \
            --exclude='node_modules' \
            --exclude='tests' \
            --exclude='.env.example' \
            .

      - name: Deploy to cPanel
        uses: SamKirkland/FTP-Deploy-Action@v4.3.4
        with:
          server: ${{ secrets.FTP_HOST }}
          username: ${{ secrets.FTP_USERNAME }}
          password: ${{ secrets.FTP_PASSWORD }}
          local-dir: ./
          exclude: |
            .git*
            .github/
            node_modules/
            tests/
            .env.example

Deploying to VPS

For VPS deployment, I prefer using SSH with rsync for efficient file transfers:

  deploy-vps:
    runs-on: ubuntu-latest
    needs: [code-quality, unit-tests, feature-tests]
    if: github.ref == 'refs/heads/main'
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup SSH
        uses: webfactory/ssh-agent@v0.8.0
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

      - name: Deploy to VPS
        run: |
          ssh -o StrictHostKeyChecking=no ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} '
            cd /var/www/your-app &&
            git pull origin main &&
            composer install --no-dev --optimize-autoloader &&
            npm ci && npm run build &&
            php artisan migrate --force &&
            php artisan config:cache &&
            php artisan route:cache &&
            php artisan view:cache &&
            sudo systemctl reload php8.2-fpm &&
            sudo systemctl reload nginx
          '

Deploying to Laravel Forge

Laravel Forge simplifies deployment significantly. You can trigger deployments via webhook:

  deploy-forge:
    runs-on: ubuntu-latest
    needs: [code-quality, unit-tests, feature-tests]
    if: github.ref == 'refs/heads/main'
    
    steps:
      - name: Deploy to Laravel Forge
        run: |
          curl -X POST "${{ secrets.FORGE_DEPLOY_WEBHOOK }}"
      
      - name: Wait for deployment
        run: sleep 30
      
      - name: Health check
        run: |
          curl -f ${{ secrets.APP_URL }}/health || exit 1

Adding Notifications

Slack Notifications

Keep your team informed with Slack notifications:

  notify-slack:
    runs-on: ubuntu-latest
    needs: [deploy-vps]
    if: always()
    
    steps:
      - name: Slack Notification
        uses: 8398a7/action-slack@v3
        with:
          status: ${{ job.status }}
          channel: '#deployments'
          text: |
            ${{ github.repository }} - ${{ github.ref }}
            Deployment Status: ${{ job.status }}
            Commit: ${{ github.sha }}
            Author: ${{ github.actor }}
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

Email Notifications

For critical deployments, email notifications provide an additional layer of communication:

  notify-email:
    runs-on: ubuntu-latest
    needs: [deploy-vps]
    if: failure()
    
    steps:
      - name: Send email notification
        uses: dawidd6/action-send-mail@v3
        with:
          server_address: smtp.gmail.com
          server_port: 587
          username: ${{ secrets.SMTP_USERNAME }}
          password: ${{ secrets.SMTP_PASSWORD }}
          subject: "🚨 Deployment Failed: ${{ github.repository }}"
          body: |
            Deployment failed for ${{ github.repository }}
            
            Branch: ${{ github.ref }}
            Commit: ${{ github.sha }}
            Author: ${{ github.actor }}
            
            Please check the GitHub Actions logs for details.
          to: dev-team@yourcompany.com
          from: ci-cd@yourcompany.com

Best Practices and Troubleshooting

Environment Variables and Secrets

Store sensitive information in GitHub Secrets:

  • DB_PASSWORD: Database passwords
  • SSH_PRIVATE_KEY: SSH keys for VPS deployment
  • SLACK_WEBHOOK_URL: Slack integration webhook
  • FORGE_DEPLOY_WEBHOOK: Laravel Forge deployment webhook

Performance Optimization

  1. Use caching: Cache composer dependencies and npm packages
  2. Parallel jobs: Run independent tests in parallel
  3. Fail fast: Put quick checks before expensive operations
  4. Selective deployment: Only deploy when tests pass

Common Issues and Solutions

Issue: Tests fail due to missing PHP extensions Solution: Specify required extensions in the setup-php action

Issue: Database connection errors Solution: Ensure database service is healthy before running tests

Issue: File permission errors Solution: Set proper permissions for storage and bootstrap/cache directories

Issue: Memory limits during testing Solution: Increase PHP memory limit or optimize test data

Complete Production-Ready Workflow

Here's a comprehensive workflow that incorporates all the concepts we've discussed:

name: Production CI/CD Pipeline

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

env:
  PHP_VERSION: '8.2'
  NODE_VERSION: '18'

jobs:
  code-quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ env.PHP_VERSION }}
          tools: phpstan, phpcs, php-cs-fixer

      - name: Cache Composer packages
        uses: actions/cache@v3
        with:
          path: vendor
          key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}

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

      - name: Run PHPStan
        run: ./vendor/bin/phpstan analyse

      - name: Check code style
        run: ./vendor/bin/php-cs-fixer fix --dry-run --diff

  test:
    runs-on: ubuntu-latest
    
    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:alpine
        ports:
          - 6379:6379

    steps:
      - uses: actions/checkout@v4
      
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ env.PHP_VERSION }}
          extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
          coverage: xdebug

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Cache Composer packages
        uses: actions/cache@v3
        with:
          path: vendor
          key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}

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

      - name: Install Node dependencies
        run: npm ci

      - name: Build assets
        run: npm run build

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

      - name: Directory Permissions
        run: chmod -R 777 storage bootstrap/cache

      - name: Run tests
        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
        run: |
          php artisan migrate --force
          ./vendor/bin/phpunit --coverage-clover coverage.xml

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          file: ./coverage.xml

  deploy:
    runs-on: ubuntu-latest
    needs: [code-quality, test]
    if: github.ref == 'refs/heads/main'
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ env.PHP_VERSION }}

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install dependencies
        run: |
          composer install --no-dev --optimize-autoloader
          npm ci

      - name: Build production assets
        run: npm run build

      - name: Deploy to production
        run: |
          # Your deployment script here
          echo "Deploying to production..."

  notify:
    runs-on: ubuntu-latest
    needs: [deploy]
    if: always()
    
    steps:
      - name: Slack Notification
        uses: 8398a7/action-slack@v3
        with:
          status: ${{ needs.deploy.result }}
          channel: '#deployments'
          text: |
            Deployment Status: ${{ needs.deploy.result }}
            Repository: ${{ github.repository }}
            Branch: ${{ github.ref }}
            Commit: ${{ github.sha }}
            Author: ${{ github.actor }}
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

Final Thoughts

Implementing CI/CD for your Laravel applications transforms how you develop and deploy software. The initial setup investment pays dividends through reduced bugs, faster deployment cycles, and increased confidence in your releases.

Start with a basic pipeline covering tests and simple deployment, then gradually add more sophisticated features like parallel testing, multiple environments, and comprehensive notifications. Remember, the best CI/CD pipeline is one that your team actually uses and trusts.

The key is to make your pipeline fast, reliable, and informative. Every minute spent waiting for a slow pipeline is a minute not spent building features your users need.


Ready to level up your Laravel development workflow? Check out my guide on Laravel Testing Best Practices to ensure your CI/CD pipeline catches issues before they reach production. And if you're looking to optimize your application performance, don't miss Laravel Performance Optimization Techniques for advanced strategies that complement your automated deployment process.

Share this article

Add Comment

No comments yet. Be the first to comment!

More from Laravel