DEV Community

Cover image for Laravel CI/CD with GitHub Actions: Test & Deploy (Guide)
Shakil Alam
Shakil Alam

Posted on Originally published at blog.shakiltech.com

Laravel CI/CD with GitHub Actions: Test & Deploy (Guide)

Series: Every Laravel Project Should Have These Building Blocks

Part: 24 of 35 Level: Intermediate Prerequisites: None


What You’ll Learn

  • The two-pipeline structure: ci.yml and quality.yml
  • Why separating fast feedback from deep analysis matters
  • The optimized ci.yml – tests + style, under 3 minutes
  • The optimized quality.yml – Larastan + security audit
  • Composer and PHP caching strategies
  • Common mistakes in Laravel CI setups

Two Pipelines, Two Purposes

Most teams start with one CI file that does everything: lint, test, static analysis, security audit. The problem: when tests fail, you wait 4 minutes before seeing a failure that a 10-second linter run would have caught first. When Larastan fails on a trivial type error, the same failure blocks the PR whether it’s a typo or a logic flaw.

Two pipelines solve this:

ci.yml – Fast feedback (< 3 minutes). Runs on every push to every branch. Tests + code style. If this fails, you don’t merge.

quality.yml – Deep analysis (< 5 minutes). Runs on push to main and on PRs. Larastan + security audit. Fails don’t block immediately – they’re signals for improvement.


ci.yml — Tests and Style

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

on:
  push:
    branches: ["**"]   # '*' doesn't match '/', so it would skip feature/... branches
  pull_request:

jobs:
  tests:
    name: 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=5

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

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: "8.4"
          extensions: pdo, pdo_mysql, bcmath, json
          tools: composer:v2
          coverage: none

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

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

      - name: Prepare environment
        run: |
          cp .env.example .env
          php artisan key:generate --ansi

      - 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 --compact --parallel
        env:
          DB_CONNECTION: mysql
          DB_HOST: 127.0.0.1
          DB_PORT: 3306
          DB_DATABASE: testing
          DB_USERNAME: root
          DB_PASSWORD: password

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

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

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: "8.4"
          tools: composer:v2
          coverage: none

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

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

      - name: Check code style
        run: vendor/bin/pint --test
Enter fullscreen mode Exit fullscreen mode

quality.yml – Static Analysis and Security

# .github/workflows/quality.yml
name: Quality

on:
  push:
    branches: [main]
  pull_request:

jobs:
  analyse:
    name: Static Analysis
    runs-on: ubuntu-latest

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

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: "8.4"
          tools: composer:v2
          coverage: none

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

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

      - name: Run Larastan
        run: vendor/bin/phpstan analyse --memory-limit=512M

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

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

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: "8.4"
          tools: composer:v2
          coverage: none

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

      - name: Run security audit
        run: composer audit
Enter fullscreen mode Exit fullscreen mode

The Most Common CI Mistakes

1. Larastan in Both Files

Running Larastan in ci.yml AND quality.yml doubles your CI minutes for zero benefit. Put it in quality.yml only.

2. Self-Copying .env

# ❌ This copies a file to itself — a no-op
- run: cp .env.testing .env.testing

# ✅ Copy example and generate a key
- run: |
    cp .env.example .env
    php artisan key:generate --ansi
Enter fullscreen mode Exit fullscreen mode

3. Missing runner.os in Cache Key

# ❌ Cache collides across different OS runners
key: composer-${{ hashFiles('**/composer.lock') }}

# ✅ OS-specific cache key
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
Enter fullscreen mode Exit fullscreen mode

4. Hardcoded DB Credentials in Matrix

Use GitHub’s services block for the database container, not hardcoded passwords in env files. The service container’s credentials are never committed to the repository.

5. coverage: xdebug When You Don’t Need Coverage

Xdebug slows test execution by 2–5×. Unless you’re generating coverage reports, use coverage: none.


PHP Version Matrix (When Needed)

If you need to test on multiple PHP versions:

jobs:
  tests:
    strategy:
      matrix:
        php: ["8.3", "8.4"]

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

    steps:
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
Enter fullscreen mode Exit fullscreen mode

For most app codebases (not packages), testing on one PHP version is enough.


FAQs

How do I set up CI for Laravel with GitHub Actions?

One workflow: checkout, PHP + composer cache, Pint, Larastan, parallel Pest/PHPUnit – the full YAML is in this post.

How do I stop duplicate GitHub Actions runs?

A concurrency group keyed on the branch with cancel-in-progress: true – pushes cancel the outdated run.

Key Takeaways

  • Two pipelines: ci.yml for fast feedback (tests + style), quality.yml for deep analysis (Larastan + security).
  • Run ci.yml on every push. Run quality.yml on PRs and merges to main.
  • Include runner.os in every Composer cache key to prevent cross-OS collisions.
  • coverage: none unless you’re actively measuring coverage – it makes tests 2–5x faster.
  • Use composer install --no-dev in the security audit job – it’s the production dependency set you actually care about.
  • --parallel on php artisan test can halve test suite time on multi-core runners.

Tips and Gotchas

⚠️ Warning: Don’t run all quality checks in a single job. A single 8-minute job that combines tests + Larastan + security audit means every push waits 8 minutes for feedback. Split fast checks (tests, Pint) into one job and slow checks (Larastan, audit) into another. Fail fast on the critical path.

💡 Tip: Use concurrency in your workflow to cancel in-progress runs when a new push arrives on the same branch. This saves CI minutes and prevents stale feedback on abandoned commits.

🔥 Expert Note: Cache Composer dependencies with actions/cache keyed on composer.lock. A cache hit cuts install time from ~60s to ~5s on every run. Similarly cache the NPM node_modules keyed on package-lock.json.

Further Reading


← Notifications and Mail | Next: Static Analysis with Larastan →

Top comments (0)