GitHub Actions Matrix Testing for Laravel: PHP × Database Combos

Run Laravel's test suite against PHP 8.2, 8.3, and 8.4 combined with MySQL, PostgreSQL, and SQLite in CI: complete GitHub Actions matrix workflow included.

Steven Richardson
Steven Richardson
· 6 min read

A client reports a broken query after upgrading their server to PHP 8.4. Your tests passed on 8.2. That's the gap a GitHub Actions matrix would have caught, running your full suite across every PHP version and database engine you support, in parallel, on every push.

Here's the complete workflow.

GitHub Actions Matrix Testing: The Core Concept#

A matrix strategy tells GitHub Actions to spin up multiple job instances from a set of variables you define. For a Laravel app supporting PHP 8.2, 8.3, and 8.4 with MySQL, PostgreSQL, and SQLite, that's nine parallel test runs on every push.

The strategy.matrix key does the work:

strategy:
  fail-fast: false
  matrix:
    php: ['8.2', '8.3', '8.4']
    db: ['mysql', 'pgsql', 'sqlite']

fail-fast: false matters. Without it, a failure in one combination cancels all the rest. You want to see all the failures, not just the first one.

Before any of this runs, a pre-commit hook running Pint keeps formatting issues out of CI entirely, no point spinning up nine test jobs against unformatted code.

The Complete GitHub Actions Matrix Workflow#

Here's the full .github/workflows/tests.yml. Copy it and swap in your test command:

name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      fail-fast: false
      matrix:
        php: ['8.2', '8.3', '8.4']
        db: ['mysql', 'pgsql', 'sqlite']

    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
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: password
          POSTGRES_DB: testing
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP ${{ matrix.php }}
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          extensions: mbstring, pdo, pdo_mysql, pdo_pgsql, pdo_sqlite
          coverage: none

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

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

      - name: Configure database environment
        run: |
          if [ "${{ matrix.db }}" = "mysql" ]; then
            echo "DB_CONNECTION=mysql"    >> $GITHUB_ENV
            echo "DB_HOST=127.0.0.1"     >> $GITHUB_ENV
            echo "DB_PORT=3306"          >> $GITHUB_ENV
            echo "DB_DATABASE=testing"   >> $GITHUB_ENV
            echo "DB_USERNAME=root"      >> $GITHUB_ENV
            echo "DB_PASSWORD=password"  >> $GITHUB_ENV
          elif [ "${{ matrix.db }}" = "pgsql" ]; then
            echo "DB_CONNECTION=pgsql"   >> $GITHUB_ENV
            echo "DB_HOST=127.0.0.1"     >> $GITHUB_ENV
            echo "DB_PORT=5432"          >> $GITHUB_ENV
            echo "DB_DATABASE=testing"   >> $GITHUB_ENV
            echo "DB_USERNAME=postgres"  >> $GITHUB_ENV
            echo "DB_PASSWORD=password"  >> $GITHUB_ENV
          else
            echo "DB_CONNECTION=sqlite"  >> $GITHUB_ENV
            echo "DB_DATABASE=:memory:"  >> $GITHUB_ENV
          fi

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

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

      - name: Run migrations
        run: php artisan migrate --force

      - name: Run tests
        run: php artisan test

The Configure database environment step writes directly to $GITHUB_ENV, making the variables available to all subsequent steps without needing a separate .env.testing file per database.

If you're using Pest architecture testing rules, add a second test step after the main run:

      - name: Run architecture tests
        if: matrix.db == 'sqlite' && matrix.php == '8.4'
        run: ./vendor/bin/pest --filter arch

The condition avoids running architecture tests nine times, once is enough.

Reducing the Matrix with exclude and include#

Nine combinations is thorough. For most apps, you don't need every PHP version against every database. The key is that each PHP version runs at least once, and each database runs at least once. Use exclude to trim without losing coverage:

strategy:
  fail-fast: false
  matrix:
    php: ['8.2', '8.3', '8.4']
    db: ['mysql', 'pgsql', 'sqlite']
    exclude:
      - php: '8.2'
        db: pgsql
      - php: '8.2'
        db: sqlite
      - php: '8.3'
        db: mysql
      - php: '8.3'
        db: sqlite

This gives you five combinations: 8.2+MySQL, 8.3+PostgreSQL, 8.4+MySQL, 8.4+PostgreSQL, 8.4+SQLite. Every PHP version and every database is covered.

Use include to bolt on one-off combinations that don't fit the grid. For example, testing against a newer MySQL version on your minimum-support PHP:

    include:
      - php: '8.2'
        db: mysql
        mysql_image: mysql:8.4  # override the service image for this one combo

Note that include can't override the service container image at runtime, that would require separate jobs. Use this pattern to add metadata variables that control test behaviour instead.

Gotchas and Edge Cases#

localhost vs 127.0.0.1: Service containers communicate over Docker networking. On ubuntu-latest runners (which run directly on the host, not inside a container), MySQL and PostgreSQL are reachable at 127.0.0.1. MySQL specifically has a habit of resolving localhost to a Unix socket rather than TCP, use 127.0.0.1 in DB_HOST and you'll avoid cryptic connection errors.

Both service containers start on every run: With this setup, MySQL and PostgreSQL spin up even for the SQLite jobs. That wastes 10–15 seconds per run. For a small matrix it's fine; if the matrix grows, split SQLite into a separate job that omits the services block entirely.

Health checks are not optional: Without them, the test step can start before MySQL or PostgreSQL is ready, producing Connection refused errors that look like test failures. The --health-cmd and --health-retries options in each service block handle this.

pdo_pgsql must be explicit: The ubuntu-latest runner doesn't ship pdo_pgsql by default. The extensions: key on shivammathur/setup-php installs it. If you omit the extension and your PostgreSQL job hits a model query, it will fail with could not find driver, not the most helpful error message.

Migrations need --force: Some environments require --force to run migrations without interactive confirmation. Add it to be safe, it's harmless in CI.

This same workflow can trigger a multi-stage Docker build as a downstream job, once the matrix passes, build and push your image with confidence that it works across your target PHP and database combinations.

Wrapping Up#

The matrix itself is straightforward once you see the full YAML. fail-fast: false, health checks on both service containers, and 127.0.0.1 as the host are the three things most developers get wrong the first time.

Once the matrix is green, the natural next step is attaching a zero-downtime deployment job to the same workflow so passing tests flow straight into production. If you want CI to do more than test, an automated bug-fixing pipeline shows how to turn a failing matrix run into an automatic fix attempt.

FAQ#

Why test against multiple PHP versions if my production server runs only 8.4?

Compatibility issues that don't surface on one PHP version often break on another. Type juggling changed between 8.2 and 8.3, deprecations may become errors, and behavior around named arguments or nullsafe operators can differ. Testing against a range ensures your code doesn't accidentally rely on 8.4-specific behavior that won't work when you or your team upgrades to future versions.

Can I reduce nine parallel jobs to fewer combinations without losing coverage?

Yes, using exclude. The key is that every PHP version and every database must run at least once somewhere in the matrix. You can exclude 8.2+PostgreSQL and 8.3+SQLite for example, as long as PostgreSQL runs with some PHP version and SQLite runs with some PHP version. Use exclude to trim redundant combinations and keep CI times reasonable.

What does fail-fast: false actually do?

By default, if one job fails, GitHub cancels all remaining jobs in the matrix. fail-fast: false allows all matrix jobs to complete even if some fail. This matters because you want to see all failures at once, if PHP 8.3 with PostgreSQL fails and 8.4 with SQLite fails, you need both failures reported, not just the first one.

Why do I need pdo_pgsql extension if I'm only using PostgreSQL in some matrix combinations?

The GitHub runner installs the extension set globally for all jobs in the matrix. The extensions: key on shivammathur/setup-php ensures pdo_pgsql is available even for SQLite-only combos, so you don't get cryptic "could not find driver" errors if your test suite happens to instantiate a PostgreSQL connection class during setup.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.