Nothing breaks a PHP package faster than an untested minor version bump from a downstream user. In this tutorial I'll walk you through a two-track strategy: running a PHP version matrix locally with Docker so you get instant feedback, then wiring the same matrix into a GitHub Actions workflow so every push is automatically validated against PHP 8.1, 8.2, and 8.3. By the end you'll have a single composer test command that works identically in both environments, and a green CI badge to prove it.
Prerequisites
- A PHP package with a
composer.jsonand a working PHPUnit test suite (vendor/bin/phpunitmust pass locally on at least one version). - Docker Desktop (or Docker Engine on Linux) installed and running.
- A GitHub repository with Actions enabled.
- Composer 2.x installed locally (only needed to regenerate the lock file if you change dependencies).
- Basic familiarity with Bash and YAML.
Step 1 — Add a Composer test Script
Centralise the test invocation in composer.json so the exact same command runs locally and in CI. Open composer.json and add or update the scripts block:
{ "scripts": { "test": "vendor/bin/phpunit --colors=always" } }
Verify it works on your current PHP version before going any further:
composer install --no-interaction composer test
If PHPUnit exits 0, you're ready to multiply that run across versions.
Step 2 — Pull the Official PHP Docker Images
Docker Hub's php:x.y-cli images are the canonical way to get a clean, version-pinned PHP environment without touching your system PHP. Pull the three versions you want to target:
docker pull php:8.1-cli docker pull php:8.2-cli docker pull php:8.3-cli
These are Alpine-based slim images — each is around 30–50 MB. The -cli variant skips the web-server overhead, which is all we need for a CLI test run.
Step 3 — Install Composer Inside Each Container at Runtime
The official php:x.y-cli images do not bundle Composer. The cleanest approach is to copy it in from the official composer:2 image at runtime. Create a small shell script called scripts/docker-test.sh at the root of your package:
#!/usr/bin/env bash set -euo pipefail
PHP_VERSIONS=("8.1-cli" "8.2-cli" "8.3-cli")
PKG_DIR="$(pwd)"
for VERSION in "${PHP_VERSIONS[@]}"; do
echo ""
echo "=========================================="
echo " Testing on PHP
V
E
R
S
I
O
N
"
e
c
h
o
"
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
=
"
d
o
c
k
e
r
r
u
n
−
−
r
m
−
−
v
o
l
u
m
e
"
VERSION"echo"=========================================="dockerrun−−rm −−volume"{PKG_DIR}:/app"
--workdir /app
--volume "
H
O
M
E
/
.
c
o
m
p
o
s
e
r
/
c
a
c
h
e
:
/
r
o
o
t
/
.
c
o
m
p
o
s
e
r
/
c
a
c
h
e
"
−
−
v
o
l
u
m
e
s
−
f
r
o
m
"
HOME/.composer/cache:/root/.composer/cache" −−volumes−from"(docker create composer:2)"
"php:${VERSION}"
sh -c "cp /usr/bin/composer /usr/local/bin/composer
&& composer install --no-interaction --prefer-dist --no-progress
&& composer test"
done
echo ""
echo "All PHP versions passed."
Gotcha: The
--volumes-fromtrick copies the Composer binary from a throwawaycomposer:2container into the PHP container's filesystem path. If your Docker version doesn't support--volumes-fromcleanly in this form, replace that section with aCOPY --from=composer:2 /usr/bin/composer /usr/bin/composerinside a tiny customDockerfileinstead.
Make the script executable:
chmod +x scripts/docker-test.sh
Step 4 — Run the Local Matrix
Execute the script from your package root:
./scripts/docker-test.sh
Docker mounts your source directory read-write, so each PHP container installs its own vendor/ directory inside the container's working copy — your host vendor/ is left untouched. The Composer cache volume (~/.composer/cache) is shared across runs so packages aren't re-downloaded for each version.
You should see three separate PHPUnit output blocks, one per PHP version, each ending with a green summary line. If a version fails, fix the incompatibility before moving on — common culprits are typed properties, readonly classes, or deprecated functions that changed between 8.1 and 8.3.
Step 5 — Create the GitHub Actions Matrix Workflow
Create the file .github/workflows/tests.yml in your repository:
name: Tests
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
phpunit:
name: PHP ${{ matrix.php-version }}
runs-on: ubuntu-latest
text
strategy:
fail-fast: false
matrix:
php-version: ["8.1", "8.2", "8.3"]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up PHP ${{ matrix.php-version }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer:v2
coverage: none
- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: ~/.composer/cache/files
key: composer-php${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
composer-php${{ matrix.php-version }}-
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --no-progress
- name: Run tests
run: composer test
Key decisions explained:
fail-fast: false— all three PHP jobs run to completion even if one fails, so you see the full picture in one CI run rather than stopping at the first failure.shivammathur/setup-php@v2— the de-facto standard action for PHP; it handles extensions, ini settings, and Composer version pinning cleanly.coverage: none— disables Xdebug/PCOV so the test run is faster. Addcoverage: xdebugon a dedicated coverage job if you need a report.
Step 6 — Push and Verify the Matrix in CI
Commit both files and push:
git add .github/workflows/tests.yml scripts/docker-test.sh composer.json git commit -m "ci: add PHP version matrix (8.1, 8.2, 8.3)" git push origin main
Open your repository on GitHub, click the Actions tab, and watch the Tests workflow trigger. You'll see three parallel jobs — one per PHP version — running simultaneously. Each job's log shows the familiar PHPUnit output you already verified locally.
Note: If a job fails only in CI but passes locally, check for environment differences: missing PHP extensions (add them to
setup-php'sextensions:key), acomposer.lockthat was committed on a different PHP version, or a test that relies on system state. The Docker local run from Step 4 narrows these gaps considerably because it uses the same base images GitHub Actions' runners use.
Wrap-up
You now have a reproducible, two-layer PHP version matrix: a local Docker script for fast iteration before you push, and a GitHub Actions workflow that validates every commit automatically. The composer test script is the single source of truth in both layers, so there's no drift between what you run on your machine and what CI runs. To add PHP 8.4 when it lands, add "8.4" to the matrix.php-version array in the workflow and the PHP_VERSIONS array in the shell script — nothing else changes. Testing a PHP package across multiple PHP versions stops being a manual chore and becomes something that just happens on every push.