cleanly, reproducibly and fast
A composer install in CI that sometimes installs different packages than expected, that redownloads 200 packages on every run, or that produces different dependencies on production than on staging: these are all solvable problems. This article explains how to run composer install in GitLab CI reproducibly, quickly and correctly.
Table of Contents
- 1. The core principle: composer install vs. composer update
- 2. composer.lock as the foundation for reproducibility
- 3. --no-dev: what gets excluded and why it matters
- 4. The most important Composer flags for CI
- 5. Configuring the Composer cache in GitLab correctly
- 6. vendor/ as a build artifact: what gets deployed
- 7. PHP image and extensions in the build job
- 8. Comparison: naive vs. reproducible Composer build
- 9. Common failure patterns in Composer builds
- 10. Summary
- 11. FAQ
1. The core principle: composer install vs. composer update
The key difference between composer install and composer update in a CI pipeline is the difference between reproducibility and chance. composer install installs exactly the versions pinned in the composer.lock file. composer update resolves new versions and updates the lock file. In a CI pipeline, composer update has no business running: it can introduce new, untested package versions that differ between two build runs. For Magento projects, whose dependencies are complex and often depend on patches, that is a serious risk.
The composer.lock file must be checked into version control. That is not a matter of preference, it is a prerequisite for reproducible builds. Without a lock file in Git, no CI pipeline can guarantee that the same packages get installed today and tomorrow. Magento projects with commercial extensions are especially exposed: an updated third party extension can contain breaking changes that only surface during the production deploy. The lock file is the record of what was tested and approved.
2. composer.lock as the foundation for reproducibility
The composer.lock file contains the exact versions and checksums of every dependency, direct and transitive. When composer install finds this file, it installs exactly those versions and verifies the checksums. If checksum verification fails, the build aborts, which is the intended behavior. A checksum mismatch is a signal that a package was tampered with or that the lock file is no longer consistent with the repository.
In GitLab pipelines, the rule is: the lock file must always match the current state of the branch. A common source of errors is a merge conflict in composer.lock that gets resolved with a simple git checkout -- composer.lock, which leaves the lock file out of sync with composer.json. The correct way to handle merge conflicts in composer.lock is to regenerate the file completely with composer update --lock, which refreshes the lock file without changing any package versions. Anyone who skips this ends up building with an inconsistent lock file, which in the worst case makes Composer abort with an error.
# Reproducible Composer install job for Magento in GitLab CI
build:composer:
stage: build
image: php:8.4-cli
variables:
COMPOSER_CACHE_DIR: ".cache/composer"
# COMPOSER_AUTH injected from GitLab CI/CD Variable (masked, protected)
cache:
# Cache key based on lock file hash, regenerates when dependencies change
key:
files:
- composer.lock
prefix: "composer-php84"
paths:
- .cache/composer/
policy: pull-push
before_script:
- apt-get update -qq && apt-get install -y -qq git unzip libzip-dev libpng-dev libicu-dev
- docker-php-ext-install zip intl bcmath gd sockets
- curl -sS https://getcomposer.org/installer | php -- --quiet
- mv composer.phar /usr/local/bin/composer
script:
# Install exactly what composer.lock specifies, no updates
- composer install
--no-dev
--prefer-dist
--no-interaction
--no-ansi
--optimize-autoloader
# Verify lock file integrity, fail if composer.lock and composer.json are out of sync
- composer validate --no-check-publish
artifacts:
paths:
- vendor/
expire_in: 2 hours
when: on_success
3. --no-dev: what gets excluded and why it matters
The --no-dev flag on composer install excludes all packages declared under require-dev in composer.json. That typically includes PHPUnit, PHPStan, PHP CS Fixer, Mockery and other testing and development tools. These packages are not needed in a production build, and excluding them has two positive effects: the vendor folder becomes smaller (a relevant factor for rsync deployments), and the autoloader map contains no development only classes that should never be loaded in production anyway.
A common mistake: --no-dev in the build job, but no separate test job that builds with dev dependencies. That leaves tests either running in production builds (with too many packages) or not running at all. The correct approach is a separate test stage job that runs composer install without --no-dev, runs unit tests and static analysis, and then discards the result. The build job that produces the deployment artifact runs separately with --no-dev. This separation makes the pipeline clearer and faster, because test jobs and build jobs can run independently of each other.
4. The most important Composer flags for CI
Besides --no-dev, there are further flags that should be standard in CI pipelines. --prefer-dist downloads packages as ready made archives instead of Git clones, which is noticeably faster and works better with the Composer cache. --no-interaction stops Composer from waiting for user input in the job, input that would never arrive in CI. A hanging job is the result when this flag is missing. --no-ansi disables color escape codes in the output, which show up as garbled characters in some CI log systems.
--optimize-autoloader (or -o) generates an optimized classmap autoloader instead of the standard PSR-4 autoloader. That measurably improves performance in production, because PHP no longer has to traverse directory paths. For Magento projects with hundreds of modules, this difference is noticeable. Composer 2 also offers the --classmap-authoritative flag, which goes a step further and makes the autoloader refuse PSR-4 fallbacks. That only makes sense with a complete classmap, and is problematic for Magento projects with dynamically generated code.
5. Configuring the Composer cache in GitLab correctly
The Composer cache in GitLab stores downloaded package archives between pipeline runs. Without a cache, every build redownloads all packages, which for a Magento project with 200+ dependencies wastes considerable time and risks hitting the Magento Marketplace rate limit. The cache key should be based on the composer.lock file: when the lock file changes, a new cache entry is created; when it does not, the existing cache is reused. That prevents cache poisoning (a stale cache used for new dependencies) and unnecessary cache misses (a fresh cache even though nothing changed).
The policy field in the cache block controls whether a job reads the cache, writes it, or both. Build jobs should use pull-push to update the cache after the build. Test jobs that use the same cache but do not download new packages should use pull, so they do not accidentally overwrite a cache with a different state. Anyone testing multiple PHP versions in their pipeline must extend the cache key with the PHP version, because some Composer packages have PHP version specific downloads.
6. vendor/ as a build artifact: what gets deployed
Passing the vendor/ directory as a GitLab artifact means that downstream jobs (deploy, verify) use exactly the same vendor directory as the build job. That rules out the possibility of a deploy job having a different PHP environment that interprets Composer differently. For Magento projects this matters in particular, because generated/ and DI code depend on the specific vendor version. Artifacts have an expiry (expire_in); 1 to 2 hours is typical for deployment artifacts, since they are no longer needed after the deploy.
What does not belong in the artifact: .cache/composer/ (that is the Composer cache, not the vendor directory), development configuration files and temporary build files. Whether vendor/ is treated as an artifact at all, or deployed directly to the server via rsync, depends on the deployment model. For multi server deployments, the artifact model is recommended, because every server receives exactly the same packages. For single server deployments, the rsync approach can be more direct, as long as the build runs on the same runner system.
7. PHP image and extensions in the build job
The PHP image in the build job must match the PHP version running on the production server. A mismatch leads to extensions or dependencies that exist in the build environment but are missing on the server, or the other way around. For Magento 2.4.8 on PHP 8.4, php:8.4-cli is the correct base image. Extensions such as intl, bcmath, zip, gd and sockets are required by Magento and must be installed in the before_script block before Composer runs, because Composer checks the PHP extension requirements from composer.json and treats missing extensions as an error.
The advantage of a Docker image over a shell runner is isolation: every build starts in a fresh, defined environment, without leftovers from previous builds. That is the foundation for reproducibility. Anyone using a shell runner must make sure that the system PHP version and all extensions on the runner system stay consistent with the production server, a requirement that gets violated quickly whenever the system is updated. The Docker image is therefore the preferred choice for build jobs.
8. Comparison: naive vs. reproducible Composer build
The difference between a quickly thrown together Composer build and a cleanly designed one in GitLab CI looks small at first glance, but is substantial in practice.
| Aspect | Naive / Fragile | Reproducible | Impact |
|---|---|---|---|
| Command | composer update |
composer install |
No untested package updates in the build |
| Dev packages | Deployed with dev dependencies | --no-dev |
Smaller vendor folder, no test code in production |
| Cache key | Branch name or no cache | Hash of composer.lock | Cache miss only on real dependency changes |
| PHP image | latest or the wrong version | php:8.4-cli (production version) |
Build environment matches production environment |
| Autoloader | Standard PSR-4 | --optimize-autoloader |
Measurably faster autoloader performance in production |
Every row of the table represents a decision that stays invisible in normal operation, but becomes visible the moment a release behaves differently on production than expected: the wrong package version, a bloated vendor folder, slow startup time, a build that works on staging but fails on production. Reproducible builds eliminate this entire class of problems systematically.
9. Common failure patterns in Composer builds
The most common failure pattern is Your lock file does not contain a compatible set of packages. That means composer.lock and composer.json are no longer consistent, for example after a merge where the conflict in composer.lock was resolved incorrectly. Fix: run composer update --lock locally and commit the regenerated lock file. The second common failure pattern is a failed download with an HTTP 401 or 403: the COMPOSER_AUTH variable is not set or has the wrong credentials. Diagnosis: check the scope of the variable and validate the JSON format.
A third, harder to diagnose failure pattern is a silent difference between the build environment and the production server: a PHP extension is installed on the build runner that is missing on the server. Composer installs packages that require this extension without an error, but the autoloader fails on the server. Prevention: keep PHP extensions in the build image explicitly aligned with production, and run composer check-platform-reqs in the build job, which checks the extension requirements of every installed package against the current PHP environment.
# Platform requirements check, catches extension mismatches early
build:validate:
stage: build
image: php:8.4-cli
needs: ["build:composer"]
script:
# Validate that composer.json and composer.lock are in sync
- composer validate --no-check-publish --strict
# Check all installed packages have their PHP extension requirements met
- composer check-platform-reqs
# Verify no security advisories for installed packages
- composer audit --no-dev
artifacts:
when: always
reports:
# Output composer audit as a JSON artifact for GitLab Security Dashboard
junit: vendor/composer/installed.json
expire_in: 1 week
10. Summary
Running composer install reproducibly in GitLab CI comes down to three things: composer install instead of update, so the lock file remains the source of truth; --no-dev, so no development packages end up in production artifacts; and a cache key based on composer.lock, so packages are not redownloaded on every build. On top of that: the PHP image must match the production PHP version, and --optimize-autoloader must be set so the generated autoloader performs well in production.
The composer.lock file belongs in the repository. That is not an optional convention, it is the technical foundation for reproducibility. A Magento build that installs different packages on two separate runs is not a reliable build. The lock file documents what was tested and approved. That documentation is what separates "it worked during the last release" from "we know exactly what is being deployed."
Composer Install in GitLab CI: the essentials at a glance
Reproducibility
composer install (not update) plus composer.lock in the repository. Identical packages guaranteed on every build run.
Production artifact
--no-dev plus --optimize-autoloader. No test tools in production, an optimized autoloader for better performance.
Cache strategy
Cache key based on composer.lock. Only refreshed on real dependency changes. Avoids Marketplace rate limits.
PHP environment
Build PHP version matches production PHP version. Run composer check-platform-reqs in the job.