Composer and Git Working Together: Versioning Dependencies
AI generated
git
HEAD
Git · Composer · Dependency Management · Magento 2
Composer and Git Working Together: Versioning Dependencies
Why composer.lock belongs in the repository, vendor does not

Teams that commit vendor directories or ignore composer.lock risk inconsistent installs between a developer machine, the CI pipeline and the production server. This article explains why composer.lock should be versioned instead of vendor, how private Magento Marketplace packages are wired up safely through auth.json, and how composer.lock merge conflicts between branches get resolved cleanly, so deployments stay reproducible at every stage.

11 min read composer.lock · vendor · auth.json Magento 2.4 · CI/CD · Deployment

1. Why Composer and Git have to be thought of together

Composer manages PHP dependencies, Git manages the history of your own source code. Both tools work separately for good reason, yet in practice the most stubborn "works only on my machine" bugs show up exactly at the boundary between the two systems: when it is unclear which Composer file actually belongs in the shared Git history and which one stays a purely local build artifact. composer.json describes desired version ranges, composer.lock pins the exact resolved dependency tree, and vendor/ is ultimately the materialized result of that resolution on disk.

The central rule that runs through this article can be stated up front: composer.lock gets committed, vendor/ does not. Almost every other practice follows from this single decision, from reproducible installs across CI pipelines to safely wiring up private Magento Marketplace packages. The following sections build this rule set step by step, including the few well-justified exceptions and the typical pitfalls when Composer and Git meet inside a team workflow.

2. composer.json and composer.lock: two files, two jobs

composer.json is authored by humans and describes version ranges as intent: "magento/module-catalog": "^2.4" means "any compatible 2.4.x version", not one exact version. composer.lock, in contrast, is machine-generated and stores, for every package including all transitive dependencies, exactly one resolved version together with a commit reference and a checksum. This separation is deliberate: composer.json stays readable and defines the allowed range, while composer.lock guarantees determinism.

The difference shows up directly in command behavior: composer install, if a lock file exists, reads exclusively from composer.lock and ignores the version ranges in composer.json entirely for already-locked packages. composer update deliberately ignores the existing lock file for the specified packages, re-resolves dependencies according to composer.json, and then writes a new lock file. Especially for Magento installs with hundreds of transitive dependencies, the lock file is the only reliable way to reproduce the exact same tree on every machine.


{
  "name": "mironsoft/magento-project",
  "type": "project",
  "require": {
    "php": "~8.4.0",
    "magento/product-community-edition": "2.4.8",
    "mironsoft/module-seosuite": "^1.2"
  },
  "repositories": [
    {
      "type": "composer",
      "url": "https://repo.magento.com/"
    }
  ],
  "minimum-stability": "stable",
  "prefer-stable": true,
  "config": {
    "optimize-autoloader": true,
    "sort-packages": true,
    "allow-plugins": {
      "magento/*": true,
      "cweagans/composer-patches": true
    }
  }
}

3. Why vendor does not belong in the Git repository

vendor/ is a generated build artifact that can be derived deterministically from composer.lock, not standalone source code. Committing it anyway makes a typical Magento project's repository grow by several hundred megabytes quickly, because every module together with all of its dependencies lands as a binary-like copy in the Git history and stays there forever, even after the files are removed in a later commit. Every small version bump of a single package also produces a huge diff spanning thousands of files that is practically unreadable for a reviewer.

Even more serious are merge conflicts inside vendor/: two branches that independently ran composer update produce conflicting file versions that allow no meaningful line-by-line resolution. The correct approach instead is to consistently add vendor/ to .gitignore and run the same composer install command against the same committed composer.lock in every environment, whether local, in the CI pipeline, or at deployment time. The single source of truth for dependencies stays clearly defined that way: composer.lock, not the materialized folder on disk.

4. Exceptions: when vendor gets versioned after all

The rule "do not commit vendor" has a few, but genuinely justified exceptions. The most common one is an air-gapped deployment without internet access to Packagist or repo.magento.com during the actual deployment step, for example in heavily locked-down enterprise or government environments. There, vendor/ either has to ship as a pre-built deployment artifact, or an internal Composer proxy server has to exist. A second, much rarer exception is projects that patch directly inside vendor/ without a proper patching mechanism, though that is an anti-pattern rather than an accepted reason.

Both cases have better alternatives than committing the entire folder. Air-gapped environments benefit from a CI pipeline that builds vendor/ once as a build artifact (zip or tarball) and ships that artifact, not the Git history, to the target system, combined with a private Composer mirror such as Satis or Private Packagist. Local patches to third-party packages belong versioned with cweagans/composer-patches: only the actual diff file is stored in your own repository, vendor/ stays generated as usual, and the patch gets reapplied automatically on every install.

5. Wiring up private Magento Marketplace packages in composer.json

Magento Commerce components and commercial extensions from the Magento Marketplace are not distributed through public Packagist, but through repo.magento.com, a private Composer repository authenticated with a public/private key pair from the Magento Marketplace or Adobe Commerce account. This repository is declared in the repositories block of composer.json, as shown in the code example in section 2. That declaration only contains the URL, no credentials whatsoever, and can therefore be committed without concern.

The actual credentials, the public and private key of the Marketplace account, belong exclusively in auth.json, a separate file in the project root that Composer recognizes automatically. Composer prompts interactively for these credentials when authentication is missing and offers to store them permanently, and this exact storage location has to be kept out of the Git repository so the separation between publicly visible configuration and secret credentials stays clean.


{
  "http-basic": {
    "repo.magento.com": {
      "username": "your-public-key-from-marketplace",
      "password": "your-private-key-from-marketplace"
    }
  },
  "github-oauth": {
    "github.com": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
  }
}

6. Keeping auth.json out of Git safely

auth.json holds highly sensitive credentials: Magento Marketplace keys, GitHub OAuth tokens for higher API rate limits, and possibly Private Packagist tokens. It belongs in .gitignore immediately on every new project, before the first composer install call ever runs. For existing repositories it is worth auditing with git log --all --full-history -- auth.json to make sure the file never slipped into an earlier commit by accident. If it did, deleting it from the current state is not enough, the credentials have to be treated as compromised and rotated.

In CI pipelines, auth.json should ideally not exist as a file in the checkout at all. Instead, Composer reads the COMPOSER_AUTH environment variable, which holds the content of auth.json as a JSON string and gets set by the pipeline from the CI system's secrets store. For local developer machines a global auth.json under COMPOSER_HOME works well, maintained across projects and, again, never inside the project repository, only on the respective machine.


# .gitignore: Composer and Magento specific exclusions
/vendor/
/auth.json
/var/
/generated/
/pub/static/*
!/pub/static/.htaccess
/app/etc/env.php

# composer.lock intentionally NOT listed here: it should be committed

7. Resolving composer.lock merge conflicts between branches

A merge conflict in composer.lock typically happens when two branches independently added or updated packages in composer.json. The lock file, besides the package list, contains a content-hash that reflects the last known composer.json, along with nested packages and packages-dev arrays holding exact versions. This structure cannot be merged line by line in any meaningful way by Git. A conflict resolved manually in an editor almost always ends up in an inconsistent state, where the content-hash no longer matches the actual composer.json.

The correct workflow runs the other way around: composer.json gets merged by hand as usual, since it is small and readable. For composer.lock, one of the two versions gets taken wholesale, for example with git checkout --theirs composer.lock, followed by running composer update --lock. This command does not resolve any new versions, it only regenerates the content-hash and the structure of the lock file to match the merged composer.json, without touching already-pinned versions. If packages were genuinely added independently by both branches, a targeted composer update package/a package/b --with-all-dependencies follows afterward.


# composer.lock merge conflict during a feature branch merge
$ git merge feature/add-payment-module
Auto-merging composer.json
CONFLICT (content): Merge conflict in composer.json
Auto-merging composer.lock
CONFLICT (content): Merge conflict in composer.lock

# 1. Resolve composer.json by hand, it is small and human-readable
$ nano composer.json
$ git add composer.json

# 2. Never hand-edit composer.lock JSON, take one side wholesale instead
$ git checkout --theirs composer.lock

# 3. Regenerate the lock file to match the merged composer.json
$ composer update --lock

# 4. If both branches added different packages, resolve dependencies too
$ composer update mironsoft/module-payment --with-all-dependencies

# 5. Stage the regenerated, consistent lock file and finish the merge
$ git add composer.lock
$ git commit

8. Semantic versioning constraints and reproducibility in CI

Version constraints in composer.json follow Semantic Versioning and control how much room composer update has for a given package. The caret ^2.4 allows any 2.x update from 2.4 upward, as long as the first non-zero digit stays the same, the usual, recommended default for well-maintained packages that take semver seriously. The tilde ~2.4 is narrower and only allows patch updates within 2.4.x. Wildcard constraints such as * or missing version specs entirely are an anti-pattern: they turn every composer update into an unpredictable black box, while overly strict exact pins block security patches unnecessarily.

Reproducibility in the CI pipeline does not come from the constraints themselves, it comes from every pipeline run installing against exactly the same committed composer.lock, never running a fresh composer update. Additional CI gates raise the safety margin further: composer validate --strict checks the consistency of composer.json, the freshness of composer.lock can be verified upfront with a simple diff check against the last known content-hash, and composer audit reports known security vulnerabilities in the exact locked versions before they reach a deployment.

9. Composer in the Magento deployment pipeline

The production Magento install typically runs through composer install --no-dev --optimize-autoloader: --no-dev skips testing and development dependencies such as PHPUnit or PHPStan, which are neither needed in production nor should they represent a security surface there, and --optimize-autoloader generates a classmap-based autoloader configuration instead of the slower PSR-4 directory lookup at runtime. What matters most is that this command runs against exactly the composer.lock already tested in the CI pipeline against the same codebase, never a differently, later-resolved version.

For Magento projects, authentication against repo.magento.com has to be available not just locally but on every CI runner and every deployment target too, usually via COMPOSER_AUTH as a pipeline secret. It is also worth building vendor/ once as a CI build artifact and passing that exact artifact through every following pipeline stage (staging, production) rather than re-resolving it in every environment. That structurally rules out an entire class of bugs like "the patch level installed on staging differs from production", because literally the same folder gets shipped.


# CI pipeline step: reproducible install using the committed lock file
$ composer validate --strict --no-check-publish
$ composer install --no-dev --optimize-autoloader --no-interaction --no-progress
$ composer audit --format=summary

# Production deployment, same lock file, same resolved dependency tree
$ COMPOSER_AUTH='{"http-basic":{"repo.magento.com":{"username":"$MAGENTO_PUBLIC_KEY","password":"$MAGENTO_PRIVATE_KEY"}}}' \
  composer install --no-dev --optimize-autoloader --no-interaction

$ bin/magento setup:di:compile
$ bin/magento setup:static-content:deploy de_DE -f

The following table sets common Composer/Git anti-patterns against the recommended approach and summarizes what matters most in each area discussed above.

Area Anti-Pattern Recommended Pattern Why
vendor/ in the repository vendor/ gets committed vendor/ in .gitignore, composer.lock committed Small repo, no unresolvable binary diffs
composer.lock handling composer.lock also ignored composer.lock always committed Guarantees an identical dependency tree everywhere
Private package access auth.json committed with tokens auth.json in .gitignore, COMPOSER_AUTH in CI Prevents leaked Marketplace keys
Lock file conflicts Conflict edited by hand in the JSON Merge composer.json, then composer update --lock Avoids an inconsistent content-hash
Version constraints Wildcard "*" or no constraint at all Caret constraints (^) plus a committed lock Controlled updates, still reproducible

Mironsoft

Git workflows, Composer setups and CI/CD pipelines for PHP and Magento teams

Reproducible deployments for your Magento store?

We help development teams set up Composer and Git workflows so every install, from local to CI to production, ships exactly the same dependency tree, including secure access to private Magento Marketplace packages.

Composer Audit

Review existing composer.json/lock setups for reproducibility and security

CI/CD Pipelines

Integrate composer install, audit gates and build artifacts cleanly into your pipeline

Marketplace Integration

Wire up repo.magento.com and private packages without leaked credentials

10. Summary

How Composer and Git work together boils down to one clear rule: composer.lock belongs in the repository, vendor/ does not. composer.json describes desired version ranges following semantic versioning, composer.lock pins the exact resolved dependency tree, and only consistently installing against that lock file in every environment, locally, in CI and at deployment, guarantees reproducible results. Exceptions like air-gapped deployments confirm the rule but solve the problem through build artifacts and private mirrors, not through committed code.

Private Magento Marketplace packages get declared via repo.magento.com in composer.json, while the actual credentials stay strictly in auth.json or COMPOSER_AUTH and never reach the repository. Lock file merge conflicts never get resolved by hand-editing JSON, but by merging the readable composer.json and then running composer update --lock. Teams that apply these principles consistently eliminate an entire class of deployment failures before they can ever happen.

Composer and Git: the key rules at a glance

Commit composer.lock

Guarantees the same dependency tree locally, in CI and in production. Never ignore it.

Exclude vendor/

A generated artifact, belongs in .gitignore. Exceptions only for air-gapped deployments via build artifacts.

Never commit auth.json

Marketplace keys and tokens belong in .gitignore, provide them via COMPOSER_AUTH in CI.

Resolve lock conflicts correctly

Merge composer.json, take over composer.lock, then run composer update --lock.

11. FAQ: Composer and Git Working Together

1Should I commit vendor/ to the Git repository?
Generally no. vendor/ is a generated artifact derived from composer.lock. Add it to .gitignore, commit composer.lock.
2What is the difference between composer.json and composer.lock?
composer.json defines version ranges as intent. composer.lock pins the exact resolved dependency tree for reproducible installs.
3When does it make sense to commit vendor after all?
Mainly for air-gapped deployments. A CI build artifact or a private Composer mirror such as Satis is usually the better option though.
4How do I wire up private Magento Marketplace packages?
Through a repositories entry for repo.magento.com in composer.json. Credentials go separately in auth.json, never committed.
5How do I keep auth.json out of Git safely?
Add it to .gitignore immediately, check history. In CI use the COMPOSER_AUTH environment variable instead of a file.
6How do I resolve composer.lock merge conflicts?
Merge composer.json by hand. Take composer.lock wholesale (e.g. checkout --theirs), then run composer update --lock.
7What exactly does composer update --lock do?
Only regenerates the content-hash and structure of the lock file to match composer.json, without changing pinned versions.
8What do caret (^) and tilde (~) mean?
Caret allows updates up to before the next major version. Tilde only allows patch updates. Wildcards like * are an anti-pattern.
9How do I ensure reproducible installs in CI?
Always install against the committed composer.lock, never re-resolve. Add composer validate --strict and composer audit as CI gates.
10Which command belongs in the Magento deployment pipeline?
composer install --no-dev --optimize-autoloader against the tested composer.lock, for a faster autoloader without test dependencies.