GitLab CI Matrix Jobs: Test Matrices for Multiple PHP and Node Versions
AI generated
CI/CD
.yml
GitLab · CI/CD · Test Matrix
Matrix jobs in GitLab CI
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.

17 min read parallel:matrix PHP versions Node.js Test coverage

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.

11. FAQ: Matrix Jobs in GitLab CI: The Essentials at a Glance

1What does parallel: matrix: do in GitLab CI?
The keyword turns a single job definition into several parallel job instances automatically, one for each specified value combination, for example one instance per PHP version.
2How many jobs does a matrix with multiple arrays create?
GitLab builds the cartesian product of all values. Three PHP versions combined with two database versions produce six job instances, one for every possible combination.
3How do I access the matrix variable in script?
Variables defined in matrix:, such as PHP_VERSION, are available as ordinary CI variables and can be used in image:, before_script:, script:, and cache:.
4Why does my cache fail between matrix jobs?
An identical cache key across all matrix cells causes jobs with different versions to share the same cache and overwrite each other. The cache key must include the matrix variable.
5Can I activate the matrix only for certain branches?
Yes, using rules: with conditions such as CI_PIPELINE_SOURCE or CI_COMMIT_BRANCH, different matrix sizes can be defined for merge requests, the main branch, or scheduled pipelines.
6How do the job names of a matrix differ in the pipeline graph?
GitLab appends the respective value combination in square brackets to the job name, for example test-php: [8.1], so every instance stays uniquely identifiable.
7At how many combinations does a matrix become impractical?
There is no fixed limit, but once a single test step produces around ten to twelve parallel jobs it is usually worth checking whether every combination is really needed or a reduced list would suffice.
8Can I exclude individual combinations from the cartesian product?
matrix: does not support directly excluding individual combinations. Instead, list the desired combinations explicitly as multiple entries within the matrix: array.
9Is a matrix worth it for internal applications?
Usually not if only a single fixed production version exists. It becomes worthwhile with planned version upgrades or when the software is used by third parties across different environments.
10How do I combine matrix with needs: for faster pipelines?
needs: can reference individual matrix job instances or the entire matrix, so subsequent stages do not have to wait for all other stage jobs but only for the matrix results they actually depend on.