Running Composer Install in GitLab CI Cleanly and Reproducibly
AI generated
CI/CD
.yml
GitLab · Composer · Magento · Build Pipeline
Running Composer Install in GitLab CI
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.

10 min read composer.lock · --no-dev · cache · artifacts Composer 2.x · GitLab 16.x · PHP 8.4

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.

11. FAQ: Composer Install in GitLab CI

1composer install instead of update, why?
install uses composer.lock as its basis: exactly the tested versions. update resolves new ones, risking untested changes in production.
2composer.lock into the repository?
Yes, mandatory. Without the lock file in the repository, there are no reproducible builds. The file documents what was tested.
3What does --no-dev give you?
Excludes PHPUnit, PHPStan and other dev tools from the artifact. Smaller vendor folder, no test code in production.
4Composer cache in GitLab, done right?
Cache key: hash of composer.lock. Path: .cache/composer/. Policy pull-push for build jobs, pull for read-only jobs.
5Lock file not compatible, what causes it?
composer.lock and composer.json are inconsistent. Fix: run composer update --lock locally and commit the regenerated lock file.
6Which PHP image?
The same version as production, for Magento 2.4.8: php:8.4-cli. A version mismatch leads to extension errors on the server.
7What does check-platform-reqs do?
Checks the PHP extension requirements of all installed packages. Prevents builds that work on the runner but fail on the server.
8vendor/ as a GitLab artifact?
Yes, downstream jobs use exactly the same vendor state. Set expire_in to 1 to 2 hours, since it is no longer needed after the deploy.
9--optimize-autoloader vs. --classmap-authoritative?
--optimize-autoloader with a PSR-4 fallback, suitable for Magento. --classmap-authoritative with no fallback, problematic for Magento with generated code.
10Tests with dev dependencies separately?
Yes, a separate test job without --no-dev, running parallel to the build job. Build produces the deployment artifact, tests use dev dependencies.