One pipeline, many PHP and Node versions
Anyone maintaining a library or tool for multiple PHP or Node versions can hardly avoid test matrices. This article shows how parallel: matrix: in GitLab CI turns a single job definition into several parallel test runs, and how to balance runtime cost against test coverage.
Table of Contents
- 1. Why test matrices are necessary in CI/CD at all
- 2. Base syntax: parallel: matrix: in .gitlab-ci.yml
- 3. Node.js matrix for frontend or tooling projects
- 4. Combined axes: PHP version x database version
- 5. Weighing runtime cost against test coverage
- 6. Selective matrix with rules: and branch-dependent reduction
- 7. Configuring caching per matrix cell correctly
- 8. When a fixed version makes more sense than a matrix
- 9. Conclusion: sizing your matrix strategy correctly
- 10. Summary
- 11. FAQ
1. Why test matrices are necessary in CI/CD at all
As soon as a PHP package or a Node.js library is used by multiple groups of users on different runtime versions, a single CI job with a hard-coded version is no longer enough. A composer.json with "php": "^8.1 || ^8.2 || ^8.3" promises compatibility with three versions, but in practice only one of them, usually the one installed on the developer's machine, actually gets tested. That is exactly where the risk lies: language features available in PHP 8.3 but not yet in 8.1, or behavioral changes in built-in functions between Node 18 and Node 20, only surface once a user on the diverging version reports an exception.
GitLab CI solves this with the parallel: matrix: keyword, which turns a single job definition into several parallel job instances, one per value combination. Instead of manually maintaining a separate job for every supported PHP or Node version and forgetting to add one when a new version ships, the list of versions is maintained centrally and the pipeline generates the matching jobs itself. This substantially reduces redundancy in the .gitlab-ci.yml and makes the actually tested version matrix visible in the pipeline graph.
2. Base syntax: parallel: matrix: in .gitlab-ci.yml
The parallel: matrix: block under a job creates a separate job instance for every entry in the PHP_VERSION array. GitLab automatically numbers these instances, for example test-php: [8.1], test-php: [8.2], and test-php: [8.3], so the pipeline graph shows at a glance which version failed. The PHP_VERSION variable is available in every cloned job and can be used anywhere an ordinary CI variable is allowed, including image:, before_script:, and script:.
Using the matrix variable directly in the image: field, as in the example with php:${PHP_VERSION}-cli, is particularly convenient. It eliminates the need to manually maintain three nearly identical jobs that only differ in their Docker image. It is important that an official image actually exists for every supported minor version; for self-built images, the tag convention must be kept consistent enough that ${PHP_VERSION} reliably resolves to an existing image.
test-php:
stage: test
image: php:${PHP_VERSION}-cli
parallel:
matrix:
- PHP_VERSION: ["8.1", "8.2", "8.3"]
before_script:
- php -v
- curl -sS https://getcomposer.org/installer | php
- php composer.phar install --no-interaction --prefer-dist
script:
- vendor/bin/phpunit --colors=never
3. Node.js matrix for frontend or tooling projects
For frontend projects, CLI tools, or Node-based build scripts, the same principle works with the official node images. It makes sense to align with the official Node LTS cycles: the currently active LTS version, the previous LTS version still in maintenance mode, and optionally the newest current version as an early warning against upcoming breaking changes. A three-value matrix usually covers the relevant user base this way, without dragging along an arbitrary number of historical versions.
The cache block in the example illustrates an important extra point: without a version-dependent cache key, all three Node versions would share the same npm cache, which can cause conflicts between incompatible binary packages, for example native Node modules that get recompiled per Node ABI. The key node-$NODE_VERSION ensures GitLab creates and reuses a separate, isolated cache for each version.
test-node:
stage: test
image: node:${NODE_VERSION}
parallel:
matrix:
- NODE_VERSION: ["18", "20", "22"]
cache:
key: "node-$NODE_VERSION"
paths:
- .npm/
script:
- npm ci --cache .npm --prefer-offline
- npm run test -- --ci
- npm run build
4. Combined axes: PHP version x database version
When multiple arrays are specified within a single matrix entry, GitLab builds the cartesian product of all values. In the example, three PHP versions and two MySQL versions produce six job instances, each with a unique combination of PHP_VERSION and DB_IMAGE. This is the right choice when every combination genuinely needs to be tested, for example because a library explicitly promises compatibility with PHP 8.1 on MySQL 5.7 and with PHP 8.3 on MySQL 8.0.
Cartesian products grow quickly, though: three PHP versions times two database versions times two operating system images already produce twelve jobs for a single test step. Adding another axis such as a Redis version doubles or triples that number again. It is therefore worth checking, before adding another matrix axis, whether the full product is really needed or whether a reduced, hand-picked list of combinations is sufficient.
test-compatibility:
stage: test
image: php:${PHP_VERSION}-cli
parallel:
matrix:
- PHP_VERSION: ["8.1", "8.2", "8.3"]
DB_IMAGE: ["mysql:5.7", "mysql:8.0"]
services:
- name: $DB_IMAGE
alias: database
script:
- php composer.phar install --no-interaction
- vendor/bin/phpunit --group=database
5. Weighing runtime cost against test coverage
Every additional line in a matrix multiplies into real runner minutes. A PHPUnit run with coverage collection that takes four minutes in isolation costs twelve job minutes per pipeline run with a 3x2 matrix, not four. On shared GitLab.com runners with a limited CI/CD minute quota, or on self-hosted runners with limited parallel capacity, that directly affects wait times until merge and the monthly bill.
A proven strategy is not to run the full matrix on every commit but to stage it: on feature branches and in merge requests, a reduced set often suffices, for example only the lowest supported and the newest version, to catch gross incompatibilities early. The full matrix with every combination then only runs on the main branch or via a nightly scheduled pipeline, where the extra runtime does not block anyone waiting for a merge request result.
6. Selective matrix with rules: and branch-dependent reduction
rules: controls which matrix variant runs in which context. In the example, test-php defines a lean two-value matrix for merge requests, while test-php-full-matrix inherits the shared configuration via extends: but expands the matrix to all three versions and is only activated for scheduled pipelines, recognizable by CI_PIPELINE_SOURCE == "schedule".
This pattern keeps the .gitlab-ci.yml maintainable because the actual test logic lives only once in test-php and gets reused via extends:. If the PHPUnit command changes, it only needs updating in one place. At the same time, it remains possible to manually trigger a scheduled pipeline with the full matrix from the GitLab UI at any time, for example before a major release.
test-php:
stage: test
image: php:${PHP_VERSION}-cli
script:
- vendor/bin/phpunit --colors=never
parallel:
matrix:
- PHP_VERSION: ["8.1", "8.3"]
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: on_success
test-php-full-matrix:
extends: test-php
parallel:
matrix:
- PHP_VERSION: ["8.1", "8.2", "8.3"]
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
when: on_success
7. Configuring caching per matrix cell correctly
A common mistake with matrix jobs is a cache key that stays identical across all matrix cells, for example a blanket cache: key: composer. Since composer install can resolve different package versions depending on the PHP version, because some packages have version-dependent constraints, incompatible vendor/ directories end up in the same cache slot and overwrite each other between the parallel-running jobs. The result is sporadic, hard-to-reproduce test failures.
The fix is to make the matrix variable part of the cache key, as in the example with composer-$PHP_VERSION. GitLab then creates a separate, isolated cache for each PHP version. The extra storage needed is small compared to the time saved by a working cache versus a full composer install without one, especially on projects with many dependencies.
test-php:
stage: test
image: php:${PHP_VERSION}-cli
parallel:
matrix:
- PHP_VERSION: ["8.1", "8.2", "8.3"]
cache:
key: "composer-$PHP_VERSION"
paths:
- vendor/
- .composer-cache/
script:
- composer install --no-interaction --prefer-dist
- vendor/bin/phpunit
8. When a fixed version makes more sense than a matrix
Not every project benefits from a matrix. An internal application running on exactly one production server with a single, team-controlled PHP version gains no practical benefit from also testing against two other versions that never get used. Here a matrix only creates extra pipeline runtime and complexity in the .gitlab-ci.yml, without ever surfacing a production-relevant bug through one of the extra versions.
A sensible middle ground for applications with a planned version upgrade is a two-value matrix of the currently live and the next planned version, for example PHP 8.2 as currently live and PHP 8.3 as the planned target upgrade in three months. That gives early feedback on upgrade compatibility without the overhead of a full, practically irrelevant version matrix. Libraries and packages used by third parties across widely varying environments, on the other hand, are the classic case for a broad matrix.
9. Conclusion: sizing your matrix strategy correctly
Matrix jobs are not an automatism every pipeline needs, but a targeted tool for projects whose target environment genuinely varies. The decision should always start with the question of who runs the software on which versions, not with which versions could theoretically be supported.
In practice, a staged strategy proves itself: a lean matrix for fast feedback in merge requests, a full matrix for release candidates and nightly scheduled pipelines, plus a deliberate cache and rules: setup so the extra test coverage is not bought at the cost of unnecessarily long wait times or incorrectly shared caches.
| Scenario | Recommended strategy | Trigger | Job count (example) |
|---|---|---|---|
| Third-party library | Full matrix of all supported versions | Every push / MR | 6-12 |
| Internal app, fixed version | No matrix, a single fixed job | Every push | 1 |
| App before planned upgrade | Two-value matrix (current + target) | Every push | 2 |
| Release candidate / nightly | Full matrix incl. database axis | Scheduled pipeline | 6-12 |
Mironsoft
CI/CD pipelines, zero-downtime deployments and release automation
Deployments that run without downtime and without the nail-biting?
We review existing GitLab pipelines for fragile deployment steps and missing safeguards, then build a release process with zero-downtime deployments, automated checks and a rollback you can actually trust in an emergency.
Pipeline Review
Checking an existing .gitlab-ci.yml for fragility, missing stages and security gaps.
Zero-Downtime Deployment
Building symlink releases, health checks and rollback strategies for Magento stores.
CI/CD Automation
Connecting tests, security scans and deployments into one reliable pipeline.
10. Summary
Matrix Jobs in GitLab CI: The Essentials at a Glance
Base syntax
parallel: matrix: automatically creates a separate job instance per value combination.
Cartesian product
Multiple arrays within one matrix entry are fully combined with each other.
Caching
The cache key must include the matrix variable, otherwise versions overwrite each other.
Cost control
Reduced matrix in merge requests, full matrix only on main or as a scheduled pipeline.