Laravel Badge

Guide

Setting Up GitHub Actions CI for Your Laravel Package

By Peter Fox

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.