GitHub Actions Matrix Testing for Laravel — PHP × Database Combos
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.
Steven is a software engineer with a passion for building scalable web applications. He enjoys sharing his knowledge through articles and tutorials.