Every Laravel package eventually hits the same problem. Tests pass locally, the release ships — then three days later someone opens an issue because it fails on PHP 8.3, or on Laravel 11, or on Windows.
The fix is usually a one-liner. The embarrassment isn't.
Unlike application code, which runs in one environment you control, a package might be installed into hundreds of combinations of PHP version, Laravel version, and operating system that you'll never see. CI can't protect you from every edge case, but it can turn "it fails on PHP 8.3" from a user complaint into a red check mark you catch before you merge.
This guide starts from a basic single-job workflow and builds to a full test matrix: multiple PHP versions, multiple Laravel versions, minimum dependency checks, Windows runs, experimental versions, and how to handle the combinations that don't make sense.
Meet the example package
Throughout this guide we'll use a fictional package: acme/laravel-gravatar. It adds a gravatar() method to your Eloquent models so you can call $user->gravatar(80) and get back a correctly sized Gravatar URL.
Here's the starting composer.json:
{
"name": "acme/laravel-gravatar",
"require": {
"php": "^8.1",
"illuminate/support": "^10.0|^11.0|^12.0"
},
"require-dev": {
"orchestra/testbench": "^8.0|^9.0|^10.0",
"phpunit/phpunit": "^10.0|^11.0|^12.0"
},
"autoload": {
"psr-4": { "Acme\\Gravatar\\": "src/" }
}
}
The illuminate/support constraint spans three major Laravel versions. That's what we need to test. Notice there's no composer.lock — package repositories should gitignore their lockfile so CI always resolves fresh dependencies against the declared constraints.
Why packages need CI
Application code runs in one environment. You control the PHP version, the OS, every dependency. A package is different.
Once it's on Packagist, it can be installed by anyone into anything. Someone running PHP 8.1 with Laravel 10. Someone on Laravel 12 with a Windows dev machine. Someone who ran composer update and pulled in a pre-release of an upstream package that quietly removed a method your code depends on.
A test matrix is how you document and verify your supported surface area in one place. It's also a credibility signal. A package with no CI is one that has never been systematically tested beyond the author's machine. A well-structured matrix is visible proof that you've thought about the range you're promising to support — and that you're checking it on every push.
Your first workflow
Create .github/workflows/tests.yml in your repository:
name: Tests
on: [push, pull_request]
jobs:
tests:
runs-on: ubuntu-latest
name: PHP 8.2
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: shivammathur/setup-php@c541c155eee45413f5b09a52248675b1a2575231 # v2.31.1
with:
php-version: '8.2'
coverage: none
- name: Install dependencies
run: composer update --prefer-dist --no-interaction
- name: Run tests
run: ./vendor/bin/phpunit
A few things worth noting:
shivammathur/setup-php is the standard action for installing PHP in GitHub Actions. It handles extensions, ini settings, and version management across platforms. Note that both actions are pinned to a commit SHA rather than a mutable tag — an attacker who gains push access to an action's repository can move a tag to a different commit without you noticing. SHA pinning prevents that.
coverage: none disables Xdebug and PCOV. Coverage collection adds real overhead. Unless you're uploading a report, skip it.
composer update rather than composer install — without a lockfile in the repository, install and update behave identically. But update is more explicit about what's happening: resolving the latest versions that satisfy your constraints.
This workflow is a start, but it only tests one PHP version. That's a remote version of your laptop, not a CI matrix.
Adding PHP versions
The strategy.matrix key expands a single job definition into multiple parallel jobs:
jobs:
tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.1', '8.2', '8.3', '8.4']
name: PHP ${{ matrix.php }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: shivammathur/setup-php@c541c155eee45413f5b09a52248675b1a2575231 # v2.31.1
with:
php-version: ${{ matrix.php }}
coverage: none
- name: Install dependencies
run: composer update --prefer-dist --no-interaction
- name: Run tests
run: ./vendor/bin/phpunit
GitHub Actions expands this into four parallel jobs. The name field becomes "PHP 8.1", "PHP 8.2", and so on in the UI, which makes it obvious at a glance which version failed.
fail-fast: false deserves attention. By default, GitHub Actions cancels all other matrix jobs the moment one fails. That saves CI minutes, but it's annoying when you want to see the full picture. If PHP 8.1 fails and you cancel the other three jobs, you don't know whether it was an isolated regression or a broader breakage. Disable it.
Adding Laravel versions
The composer.json supports Laravel 10, 11, and 12. Those are three different API surfaces. The matrix can hold multiple dimensions:
strategy:
fail-fast: false
matrix:
php: ['8.1', '8.2', '8.3', '8.4']
laravel: ['10', '11', '12']
This produces a 4×3 grid: twelve parallel jobs. But there's a problem — not every cell in this grid is valid.
Laravel 10 supports PHP 8.1, 8.2, 8.3, and 8.4. Laravel 11 requires PHP 8.2 or higher — it dropped PHP 8.1. Laravel 12 also requires PHP 8.2 or higher.
If you let GitHub Actions run the full grid unmodified, the PHP 8.1 / Laravel 11 and PHP 8.1 / Laravel 12 cells will fail with a Composer resolution error. That failure is noise — it tells you your matrix is wrong, not that your package is broken.
Installing the right Laravel version
Before handling exclusions, you need to actually install the target Laravel version in each job. The cleanest approach is to pin the framework constraint before resolving:
- name: Install dependencies
run: |
composer require "laravel/framework:^${{ matrix.laravel }}" --no-interaction --no-update
composer update --prefer-dist --no-interaction
The first line updates the in-memory constraint without downloading anything. The second resolves and installs everything. Orchestra Testbench's version constraint aligns with Laravel (^8.0 for Laravel 10, ^9.0 for Laravel 11, ^10.0 for Laravel 12), so it picks the right version automatically.
Excluding invalid combinations
The exclude key drops specific cells from the matrix:
strategy:
fail-fast: false
matrix:
php: ['8.1', '8.2', '8.3', '8.4']
laravel: ['10', '11', '12']
exclude:
- php: '8.1'
laravel: '11'
- php: '8.1'
laravel: '12'
The matrix is now ten jobs. PHP 8.1 runs against Laravel 10 only — which is exactly what the compatibility table says.
Every entry under exclude must match at least one existing cell exactly. If you typo a value, GitHub Actions silently ignores the entry rather than warning you, so double-check the values against your matrix dimensions.
Testing minimum dependencies
Your composer.json says "php": "^8.1" and "illuminate/support": "^10.0|^11.0|^12.0". But have you ever run your tests against the oldest versions of those dependencies that those constraints actually permit?
If your package uses a method added in illuminate/support 10.12, your constraint of ^10.0 is wrong — it promises support back to 10.0. The only way to catch this automatically is composer update --prefer-lowest, which resolves the minimum allowed version of each dependency rather than the latest.
Add a dependency-version dimension:
strategy:
fail-fast: false
matrix:
php: ['8.1', '8.2', '8.3', '8.4']
laravel: ['10', '11', '12']
dependency-version: ['prefer-stable', 'prefer-lowest']
exclude:
- php: '8.1'
laravel: '11'
- php: '8.1'
laravel: '12'
And use it in the install step:
- name: Install dependencies
run: |
composer require "laravel/framework:^${{ matrix.laravel }}" --no-interaction --no-update
composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction
This doubles the matrix to twenty jobs. Some teams run prefer-lowest only on the minimum supported PHP version to keep things compact — that's a reasonable tradeoff for larger matrices. But running it across all versions will catch cases where a minimum-version incompatibility only surfaces on a specific PHP version combination.
The prefer-lowest failures tend to be the most instructive. They tell you what you've promised that you haven't actually tested.
Testing on Windows
Most PHP packages run on Linux servers, but some interact with the filesystem or path handling in ways that break on Windows — directory separators, case-insensitive paths, line endings. Running even a narrow set of Windows jobs catches these before your Windows users do.
Add an os dimension and use it in runs-on:
jobs:
tests:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: ['ubuntu-latest', 'windows-latest']
php: ['8.2', '8.3', '8.4']
laravel: ['11', '12']
Note the narrower PHP and Laravel ranges for Windows. Running the full cross-product including Windows is expensive in CI minutes and most of those combinations don't add signal beyond what the Linux jobs already cover. Test enough Windows coverage to catch platform-specific bugs — not every possible version combination.
If your package has no filesystem interaction at all, you can skip Windows entirely and document that in the README.
Allowing failures for cutting-edge versions
PHP 8.5 will ship. Laravel 13 will ship. You probably want to know whether your package will work with them before they're stable — but you don't want a pre-release incompatibility to block merges on your main branch.
GitHub Actions has a continue-on-error key that marks a job as allowed to fail without failing the overall check. Combined with an experimental flag in the matrix, it's the right tool for this:
strategy:
fail-fast: false
matrix:
php: ['8.2', '8.3', '8.4']
laravel: ['11', '12']
experimental: [false]
include:
- php: '8.5'
laravel: '12'
dependency-version: 'prefer-stable'
experimental: true
jobs:
tests:
continue-on-error: ${{ matrix.experimental }}
The experimental: [false] seeds the default value for every regular matrix job. The include entry adds the PHP 8.5 cell with experimental: true. Jobs with continue-on-error: true show a yellow warning icon in the GitHub UI instead of a red failure — the overall check still passes.
When PHP 8.5 stabilises and you've updated your package to support it, move '8.5' into the regular php array and remove the include entry.
Conditional includes for special cases
The include key can also extend existing matrix cells rather than only adding new ones. When every key in an include entry matches an existing cell exactly, additional keys are merged into that cell. This is useful for adding per-cell configuration without creating separate jobs.
A common use case is running prefer-lowest only on the minimum supported combination, keeping the matrix compact, while still verifying your declared minimums:
strategy:
fail-fast: false
matrix:
php: ['8.1', '8.2', '8.3', '8.4']
laravel: ['10', '11', '12']
dependency-version: ['prefer-stable']
exclude:
- php: '8.1'
laravel: '11'
- php: '8.1'
laravel: '12'
include:
# Minimum-deps check on the oldest supported combination only
- php: '8.1'
laravel: '10'
dependency-version: 'prefer-lowest'
# Coverage report on one nominated combination
- php: '8.3'
laravel: '12'
dependency-version: 'prefer-stable'
coverage: true
You can then use matrix.coverage in the setup-php step:
- uses: shivammathur/setup-php@c541c155eee45413f5b09a52248675b1a2575231 # v2.31.1
with:
php-version: ${{ matrix.php }}
coverage: ${{ matrix.coverage == true && 'xdebug' || 'none' }}
Xdebug only loads for the single nominated coverage job. Every other job runs without it.
One thing to be aware of: the include entry { php: '8.1', laravel: '10', dependency-version: 'prefer-lowest' } adds a brand new cell (since prefer-lowest is not in the base dependency-version array), not a modification of an existing one. The result is an eleventh job with that specific combination. This is usually what you want — a deliberate one-off.
The complete workflow
Here's the full .github/workflows/tests.yml for acme/laravel-gravatar, combining everything from this guide:
name: Tests
on: [push, pull_request]
jobs:
tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.1', '8.2', '8.3', '8.4']
laravel: ['10', '11', '12']
dependency-version: ['prefer-stable', 'prefer-lowest']
experimental: [false]
exclude:
- php: '8.1'
laravel: '11'
- php: '8.1'
laravel: '12'
include:
- php: '8.5'
laravel: '12'
dependency-version: 'prefer-stable'
experimental: true
name: "PHP ${{ matrix.php }} / L${{ matrix.laravel }} / ${{ matrix.dependency-version }}"
continue-on-error: ${{ matrix.experimental }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: shivammathur/setup-php@c541c155eee45413f5b09a52248675b1a2575231 # v2.31.1
with:
php-version: ${{ matrix.php }}
coverage: none
- name: Install dependencies
run: |
composer require "laravel/framework:^${{ matrix.laravel }}" --no-interaction --no-update
composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction
- name: Run tests
run: ./vendor/bin/phpunit
Twenty-one jobs total — ten PHP/Laravel/stable combinations, ten PHP/Laravel/lowest combinations, and one experimental PHP 8.5 run. All parallel, none cancelling others on failure. The complete picture of what your package promises to support, verified on every push.
Keeping it honest
A test matrix that passes is not proof your package is correct. It's proof your package passes its own tests on the combinations you chose to test. The matrix is only as good as the tests it runs.
That's worth keeping in mind as you expand your coverage. Adding more matrix dimensions catches more environment-specific failures — but it doesn't compensate for tests that don't exercise the interesting behaviour. Both matter.
The badge that shows green on your README is a compact statement: this combination of PHP versions and Laravel versions was tested today, on a clean machine, and the tests passed. Your matrix defines what that statement covers. Keep the matrix honest about what you actually support, keep the tests honest about what your code actually does, and the badge means something.