Composer: Dependency Management and Supply-Chain Safety

Published Updated

Composer manages PHP dependencies using two documents: a shopping list of acceptable versions and a receipt of exactly what was selected. Confuse the two and a routine deployment can install different code from the code you tested.

The decision that matters is when to run install and when to run update, and I want every update visible in the lock diff before it is accepted. This guide reads composer.json and composer.lock line by line, then treats autoloading and security checks as separate jobs.

Before You Start

  • Use a local PHP project and Composer.
  • Know basic PHP classes and namespaces, and run commands from your project's root directory.
  • The policy configuration follows Composer 2.10 documentation. Check your Composer version before using those settings; older releases have different audit controls.

What Is Composer?

Composer installs libraries per project, inside the project, instead of across the whole server.

The official documentation draws the line itself: Composer is "not a package manager in the same sense as Yum or Apt are", because it manages packages "on a per-project basis" and "by default, it does not install anything globally". Two projects on the same server can therefore run different versions of the same library without interfering with each other.

It also handles the dependencies of your dependencies. If you ask for a logging library and that library needs an interface package, Composer finds a version of the interface package that satisfies both, and installs it as well. That resolving step is the main thing a dependency manager adds over copying library folders into a project by hand.

Packagist and Package Names

A published library's name has two parts, a vendor and a project, as in monolog/monolog or symfony/console. Platform requirements are the exception: names like php, ext-pdo, and lib-curl describe your environment rather than a package to download. The vendor half exists to prevent clashes, so two different people can both publish a library called json under their own vendor names.

Packagist.org is the default repository Composer searches. When your composer.json does not register any other repository, a name like monolog/monolog is looked up on Packagist. Private or internal packages can come from other repositories that you register with the repositories key.

composer.json vs composer.lock

A Composer project keeps two files at its root, and each one answers a different question. composer.json answers "which versions would we accept?", while composer.lock answers "which exact versions will every install use?". The shopping list and the receipt map onto them directly.

FileWritten ByHolds
composer.jsonYou, or composer requireDirect dependencies and version ranges
composer.lockComposer, during updateEvery installed package, exact versions

What composer.json Declares

This is a small composer.json for an application, trimmed to the parts this page teaches. It accepts PHP from 8.4 up to, but not including, 9.0, and it asks for the Monolog logging library, PHPUnit for tests, and an autoloader for the project's own code:

{
  "require": {
    "php": "^8.4",
    "monolog/monolog": "^3.12"
  },
  "require-dev": {
    "phpunit/phpunit": "^13.3"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}

The php entry is a platform package, which Composer treats as a requirement it can check but cannot install. The same applies to extensions written as ext- names, such as ext-pdo, and to system libraries written as lib- names. Ordinary entries such as monolog/monolog name real packages that Composer downloads.

Installed packages can raise the bar further. PHPUnit 13.3 itself requires PHP 8.4.1 or later, so this project cannot install its development tools on PHP 8.4.0 even though its own constraint allows it.

What composer.lock Records

composer.lock lists every package in the resolved dependency tree, including the ones you never asked for by name. Each entry carries the exact version plus the download details Composer used, and a Git source entry, when present, includes the commit reference. It is the receipt, itemised down to the batch number. It records what Composer resolved, which can differ from what sits in vendor: update --no-install writes the file without installing anything, and install --no-dev leaves the development packages locked but not installed.

The file also stores a content-hash of the relevant parts of composer.json. When you run install, Composer compares that hash with the current file, and that comparison is how it notices a list that has changed since the receipt was printed.

Adding Your First Dependency

The quickest way to add a package is composer require, which edits composer.json, resolves the new package, updates composer.lock, and installs the result in one step:

composer require monolog/monolog

With no version given, Composer picks a suitable constraint from the available releases and writes it into composer.json. Open the file afterwards and read what it chose, because that constraint decides what every future update is allowed to install. You can also state the constraint yourself:

composer require "monolog/monolog:^3.12"

To use the package, load Composer's autoloader once at the entry point of your application, then use the classes by their full names:

<?php
require __DIR__ . '/vendor/autoload.php';

use Monolog\Logger;
use Monolog\Handler\StreamHandler;

$log = new Logger('billing');
$log->pushHandler(
    new StreamHandler('app.log')
);
$log->warning('Invoice total is zero');

That script appends a warning line to app.log. There is no require for any Monolog file, because the autoloader finds each class the first time the code uses it.

require vs require-dev

The require section lists what the application needs to run, while require-dev lists what developers need to build and test it. Test frameworks and static analysers belong in require-dev. Add a development package with the --dev flag:

composer require --dev phpunit/phpunit

Both sections are installed by default. A production deploy should pass --no-dev to install, which skips the require-dev packages and the autoload-dev rules. That keeps test tooling off the server and shrinks the amount of third-party code an attacker could reach there.

Both sections are recorded in the same lock file, in separate packages and packages-dev lists. Only the root project's require-dev counts, so the development dependencies of the libraries you install are never pulled in.

How Do Version Constraints Work?

A version constraint is the "any brand" part of the shopping list. It tells Composer which releases of a package are acceptable, and the Composer documentation defines each operator precisely:

ConstraintAllowsTypical Use
^1.2.3>=1.2.3 <2.0.0Most packages
^0.3>=0.3.0 <0.4.0Pre-1.0 packages
~1.2.3>=1.2.3 <1.3.0Patch releases only
~1.2>=1.2 <2.0.0Minor and patch releases
1.2.*>=1.2 <1.3One minor line
1.2.3Exactly 1.2.3Deliberate hard pin

The caret is the one to learn first. It permits every release that semantic versioning promises will not break your code, and it treats pre-1.0 packages more cautiously, because a 0.x package makes no such promise between minor versions. The documentation calls it "the recommended operator for maximum interoperability when writing library code".

A constraint only protects you as far as the package's maintainers follow semantic versioning. A caret range will happily install a new minor release that breaks something, if the maintainers shipped a break without bumping the major version.

Why the Lockfile Pins More Than composer.json Can

An exact version in composer.json pins one package. It says nothing about that package's own dependencies, which a full composer update is free to move to any version that fits. A project with three direct dependencies can easily install forty packages, and composer.json constrains only the three you named.

The lock file pins all forty of them to exact versions. That makes it the reproducibility control for an application. It does not establish that those versions are trustworthy; package review and advisory checks still matter.

Exact pins also carry a cost the documentation states plainly: if another dependency needs a different version, "the solver will ultimately fail and abort any install or update procedures".

For an application, the combination I would recommend is caret ranges in composer.json and a committed lock file. The ranges let a deliberate update pick up fixes, and the lock file keeps every install identical until you choose to move.

The trade-off is that a caret range trusts the maintainers' semantic versioning, as the section above showed. Reserve an exact pin for a package you have a specific, written-down reason to hold back, and accept that the pin can make the solver fail when another package needs a different version.

install vs update: Which Command Changes What?

composer install buys exactly what is on the receipt, and composer update goes back to the shop with the list and prints a new receipt. A common dependency accident in a PHP team is someone running the second command when they meant the first.

CommandVersions Come FromRewrites the Lock File
composer installcomposer.lockOnly if none exists
composer updateConstraints in composer.jsonYes
composer requireThe new constraintYes

Run install after cloning a project or pulling a colleague's changes, because it syncs your vendor directory with the committed lock file. Run update only when you intend to move to newer versions, and then review and commit the lock file it produces. To move a single package, name it:

composer update monolog/monolog

That updates Monolog within its composer.json constraint and leaves every other package at its locked version. If one of Monolog's own dependencies also needs to move, add --with-dependencies (-w). That flag skips any dependency that is also one of your root requirements. When such a package must move as well, use --with-all-dependencies (-W); the constraint you declared for it in composer.json still applies.

What CI and Production Should Run

A build server and a production deploy should install from the lock file and never resolve anything new. A typical production install looks like this:

composer install \
  --no-dev \
  --no-interaction \
  --optimize-autoloader

In CI, run composer validate --strict before the install. It checks that composer.json is valid and that the lock file is up to date with it, and --strict turns warnings into a failing exit code.

The trimmed example above would not pass that gate. Composer's strict schema wants a name and a description, and it reports a missing one as a publish error, which fails validation even without --strict. A missing license is a general warning, which is exactly what --strict turns into a failure. Add all three to the example before you use it as a CI gate.

For an application that will never be published as a package, --no-check-publish removes the name and description requirement. The license warning survives that flag, so the application still needs a license entry, and "proprietary" is the value Composer's own warning suggests for closed-source software. A pull request that edits composer.json without regenerating the lock file then fails in review instead of in production.

Autoloading Your Own Code with PSR-4

The autoload section in the earlier example maps the App\ namespace prefix to the src/ directory. Under PSR-4, the class App\Mail\Mailer then lives in src/Mail/Mailer.php, because the prefix is removed and the rest of the namespace becomes the path:

<?php
// src/Mail/Mailer.php
namespace App\Mail;

final class Mailer
{
    public function from(): string
    {
        return 'billing@example.com';
    }
}

Any script that has loaded vendor/autoload.php can now write new \App\Mail\Mailer() with no require for the file. The namespace prefix must end with a double backslash in JSON, written "App\\", so that App cannot accidentally match an AppTools namespace.

After you change the autoload section itself, regenerate the autoloader:

composer dump-autoload

During development, a new class file under an existing PSR-4 mapping loads without that command, because the standard autoloader looks the file up by its path. The exception is the authoritative class map (-a) covered below. Ordinary optimization with -o still falls back to the PSR-4 rules for a class it has not mapped.

Test Classes Belong in autoload-dev

Test classes get their own mapping in autoload-dev, usually something like "App\\Tests\\": "tests/". The Composer documentation gives the reason: test classes in the main rules would pollute the production autoloader. Because install --no-dev skips autoload-dev, production never registers the test namespace at all.

The Optimized Autoloader

--optimize-autoloader, or -o, converts the PSR-4 rules into a class map, a single lookup table from class name to file. Classes missing from that map still fall back to the PSR-4 rules.

--classmap-authoritative, or -a, goes one step further and loads classes from the class map only, so a class added after the dump is simply not found until you dump again.

Which one you use depends on how fixed your class list is. On a production deploy, where no class files appear after the dump, -o is the sensible default and -a is the stricter option. During development, leave both off, because a new class file should load without a fresh dump; with -a in place, every new class needs one.

Checking for Vulnerabilities with composer audit

composer audit checks the packages in your project against published security advisories. It queries the Packagist.org advisory API by default, or other repositories if your composer.json configures them. Current Composer releases also report abandoned packages and package versions flagged as malware in the same run.

composer audit --locked

The --locked option audits the versions in composer.lock regardless of what is in vendor, which suits a CI job that has not installed anything yet. --no-dev leaves out development packages, and --format=json gives a machine-readable report.

With the default failure policy, current releases exit with 1 for findings that fail the audit and 0 for a passing audit. A policy set to report or ignore can change which findings fail the command, so check the policy as well as the exit code.

Composer also audits automatically after update, require, and remove, unless you pass --no-audit. A plain install does not audit unless you add --audit, which is a good reason to keep a separate audit step in CI.

What Composer Blocks on Its Own

Recent Composer releases go beyond reporting problems after the fact. Under the default policy, an update, require, or remove refuses to install a package version that has an active security advisory. Package versions flagged as malware are blocked during install as well.

Older Composer versions lack some or all of this blocking, so keeping Composer itself current is part of the job.

When you must accept an advisory, for example because the vulnerable function is never called, record the decision in composer.json with a reason. Current releases use a policy block for this:

{
  "config": {
    "policy": {
      "advisories": {
        "ignore-id": {
          "CVE-1234": "Not affected."
        }
      }
    }
  }
}

Replace CVE-1234 with the real advisory ID and write a reason a reviewer can check. An ignore entry with no reason is how an accepted risk turns into a forgotten one.

Supply-Chain Safety Beyond the Audit

An audit only knows about problems somebody has already reported. A compromised release or a typosquatted package name can reach your lock file before any advisory exists, so the habits around updates matter as much as the tooling.

  • Commit composer.lock for every application, and treat a change to it as a code change that needs review.
  • Check every new package name that appears in a lock diff, since a misspelled vendor name is the classic typosquatting trick.
  • Update deliberately, in small batches, instead of running a blanket composer update on the day of a deploy.
  • Run composer audit in CI on every pull request, and keep Composer itself up to date.

Plugins and Scripts

Composer runs the scripts defined in your own composer.json, and the documentation is explicit that scripts declared by your dependencies are never executed. Plugins are the exception that matters for supply-chain safety. A Composer plugin is a package that runs its own code while Composer is running, so the allow-plugins setting exists to limit which plugins may do that.

{
  "config": {
    "allow-plugins": {
      "phpstan/extension-installer": true
    }
  }
}

The default is an empty object, which allows no plugins. When a plugin that is not listed first activates, Composer prints a warning, and an interactive run then asks whether to execute it. A non-interactive run, such as CI or a deployment, fails instead, unless the plugin declares plugin-optional, in which case Composer skips it. List every plugin explicitly before the first automated run: approve each one by name, set the ones you do not want to false, and avoid setting allow-plugins to true. The documentation marks that setting "NOT recommended", because it lets every plugin in the tree execute code.

None of this stops a package's code from running inside your application. Once vendor/autoload.php is loaded, any file a package registers for autoloading and any class your code uses run in your process with your application's permissions, which is why the lock diff review above matters.

Dependabot Alerts for Composer Packages

GitHub's Dependabot supports Composer for both version updates and security updates. On 28 July 2026, GitHub announced that its Advisory Database now ingests malware advisories from the OpenSSF malicious-packages project, widening malware coverage beyond npm. GitHub's malware alerts documentation now lists Composer among the supported ecosystems.

Malware alerts are a separate setting from ordinary Dependabot alerts. In the repository's settings, under Advanced Security, Dependabot alerts must be enabled first, and then Dependabot malware alerts can be enabled beside them. GitHub is candid about the limits: only advisories reviewed by GitHub trigger alerts, and new malware "may take time to appear" in the database.

Scheduled version updates for Composer need a .github/dependabot.yml file:

version: 2
updates:
  - package-ecosystem: "composer"
    directory: "/"
    schedule:
      interval: "weekly"

A Dependabot pull request may change composer.json, composer.lock, or both, depending on its versioning strategy. Give it the same lock diff review as an update you ran yourself. The Dependabot guide covers the alert types and their trade-offs in more depth.

Walkthrough: Reviewing a Dependency Update

This walkthrough moves one package forward and checks the receipt before committing it. Start by listing which direct dependencies have newer releases:

composer outdated --direct

The list colours each package by risk: red for a semver-compatible update you should take, and yellow for one that semantic versioning marks as potentially breaking and that may need code changes. On a pre-1.0 package, a move from 0.3 to 0.4 counts as potentially breaking, just as a new major version does. Pick one package and update only that:

composer update monolog/monolog

Composer resolves the new version, rewrites composer.lock, installs it, and then audits the result. Any approved plugin and any script in your own composer.json can run during that install, before you have read the change. When you want the review to come first, add --no-install --no-plugins --no-scripts to the same command: Composer then rewrites and audits the lock file without installing anything, and a composer install after your review brings vendor into line.

Either way, read the receipt before anything else:

git diff composer.lock

In the diff, look for three kinds of change. A package's version and reference moving is expected. A package name that was not there before needs an explanation, and a source URL that points somewhere new needs a closer look. Ask Composer who pulled in an unfamiliar package:

composer why psr/log

why is an alias of depends, and it lists every installed package that requires the one you named. If nothing you recognise needs it, stop and investigate before going further. Then run the same checks CI will run:

composer validate --strict
composer audit --locked

Finally, run the test suite and commit both files in the same commit, so the list and the receipt never disagree in history:

git add composer.json composer.lock
git commit -m "Update monolog/monolog"

Common Pitfalls and Debugging Tips

The four errors below trace back to the list and the receipt disagreeing, or to a server that differs from the machine the receipt was printed on.

The Lock File Is Not Up to Date

Running composer install after editing composer.json by hand prints this warning, which Composer writes on a single line and is wrapped here to fit:

Warning: The lock file is not up to
date with the latest changes in
composer.json. You may be getting
outdated dependencies. It is
recommended that you run `composer
update` or `composer update
<package name>`.

If the locked versions still satisfy the edited composer.json, the install goes ahead with them, so your change has no effect yet. If they no longer satisfy it, for example because you added a package the lock file does not contain, the install fails.

Either way, fix it by running composer update for the package you changed, then commit both files. composer update --lock refreshes the lock file's hash and package metadata without changing any locked version, so reserve it for when the locked versions really should stay as they are.

Editing only the description doesn't make the lock file stale in the first place.

Class Not Found After Adding a Class

PHP 8 reports a missing class as Class "App\Mail\Mailer" not found. With Composer autoloading, the cause is nearly always one of these:

  • The namespace in the file does not match its path under the PSR-4 mapping, often through a capitalisation difference that a case-insensitive file system on a laptop hid.
  • The autoload section changed and composer dump-autoload was never run.
  • The server uses an authoritative class map, built with -a, before the class existed.
  • The class lives in autoload-dev and the server installed with --no-dev.

Your Requirements Could Not Be Resolved

The message "Your requirements could not be resolved to an installable set of packages." means no combination of versions satisfies every constraint at once. The lines Composer prints below it name the packages in conflict. To ask why a particular version cannot be installed, use why-not, an alias of prohibits:

composer why-not monolog/monolog 4.0

The fix is to loosen the constraint that blocks the upgrade, or to update the package that holds the conflicting requirement in the same command.

The Lockfile Was Resolved on a Newer PHP

Composer resolves versions against the PHP version of the machine running update. A developer on a newer PHP than production can lock a release that production cannot run. Declare the production PHP version in the platform config, so every update resolves as if it ran on the server:

{
  "config": {
    "platform": {
      "php": "8.4.12"
    }
  }
}

The Composer documentation adds one caution about this setting. A faked platform means Composer no longer checks the real one, so run composer check-platform-reqs during deployment to confirm the server actually meets the requirements.

Best Practices and Trade-offs

Applications and libraries use Composer differently, because only the root project's lock file is ever used:

Project TypeConstraintsCommit the Lock File
ApplicationCaret ranges, rare exact pinsAlways
LibraryCaret ranges, as wide as testedOptional, affects only its own team

A library that pins exact versions forces its choices on every project that installs it, and the solver fails as soon as two packages disagree. Keep the ranges wide in libraries and do the pinning in the application's lock file, where it belongs.

  • Run install everywhere except the one deliberate update, and never run update on a production server.
  • Install production with --no-dev and an optimized autoloader.
  • Gate CI on composer validate --strict and composer audit --locked.
  • Approve plugins by name, and record every ignored advisory with a reason.
  • Turn on Dependabot alerts and malware alerts for the repository, then review its pull requests like any other lock change.

Conclusion

composer.json is the shopping list and composer.lock is the receipt. The list says what you would accept, the receipt says exactly which versions were resolved, and install reproduces the receipt while update prints a new one.

The supply-chain side follows directly from that distinction. Commit the receipt, read every new one before you accept it, and let composer audit and Dependabot check it against recall notices.

Neither tool catches a tampered package before somebody reports it, so the lock diff review is the part that stays your responsibility. For a PHP application, a committed lock file plus a reviewed update is the minimum I would accept before code reaches a server.

Frequently Asked Questions

Should composer.lock be committed to Git?

For an application, yes, always commit it. The lock file is what makes every developer machine and every server install the same versions. For a library it is optional, because a library's lock file has no effect on the projects that install that library.

Should the vendor directory be committed?

Usually not, for an application with a committed lock file. The Composer documentation recommends adding vendor to .gitignore, because the committed lock file already lets anyone rebuild the same vendor directory with composer install. Committing it adds a large volume of third-party code to every diff.

What is the difference between caret and tilde in Composer?

The caret allows every non-breaking release under semantic versioning, so ^1.2.3 means at least 1.2.3 and below 2.0.0. The tilde lets only the last digit you wrote go up, so ~1.2.3 stays below 1.3.0 while ~1.2 stays below 2.0.0.

Does composer install ever change composer.lock?

Without a lock file, install resolves dependencies and creates composer.lock. With a lock file, it installs the recorded versions rather than updating them. A relevant composer.json change can trigger a warning or an install failure when the locked requirements no longer fit; install does not repair that mismatch.

How do you update only one Composer package?

Name it on the command line, as in composer update monolog/monolog. Composer updates that package within the constraint in composer.json and leaves the others at their locked versions. Add --with-dependencies when one of its own dependencies also needs to move, or --with-all-dependencies when that dependency is also one of your own root requirements.

Where does composer audit get its advisory data?

By default it queries the security advisory API on Packagist.org, or other repositories when your composer.json configures them. Current Composer releases also report abandoned packages and package versions flagged as malware in the same run.

Does Dependabot support Composer projects?

Yes, Dependabot works with Composer projects on GitHub. It can open version-update and security-update pull requests, and GitHub's documentation lists Composer among the ecosystems covered by Dependabot malware alerts. Malware alerts are a separate setting, enabled after Dependabot alerts are turned on.

Self-Check

  1. A colleague pushes a change to composer.lock. Which command brings your vendor directory in line with it?
  2. Which versions does ~2.4.1 allow, and which does ^2.4.1 allow?
  3. Your composer.json pins every direct dependency to an exact version. Why is the lock file still needed?
  4. The class App\Billing\Invoice sits under the mapping "App\\": "src/". Which file does the autoloader look for?
  5. A lock diff after composer update shows a package you have never heard of. Which command tells you what required it?
  6. Why does a plain composer install in CI not replace a separate composer audit step?

Answers

  1. composer install, which installs the exact versions recorded in the lock file without resolving anything new.
  2. ~2.4.1 allows at least 2.4.1 and below 2.5.0. ^2.4.1 allows at least 2.4.1 and below 3.0.0.
  3. composer.json constrains only the packages you named. A full update can move their dependencies to any version that fits, and only the lock file records the exact versions that were resolved.
  4. src/Billing/Invoice.php, because PSR-4 removes the App\ prefix and turns the rest of the namespace into the path.
  5. composer why vendor/package, the alias of depends, which lists every installed package that requires it.
  6. install only runs an audit when you pass --audit. The automatic audit happens after update, require, and remove, which CI normally does not run.

Next Steps

Composer shows up in every modern PHP project, so the pages below all build on it:

Sources

  1. [1]
    Composer Introduction
    (getcomposer.org)
  2. [2]
    Composer Basic Usage
    (getcomposer.org)
  3. [3]
  4. [4]
    The composer.json Schema
    (getcomposer.org)
  5. [5]
    Composer Config
    (getcomposer.org)
  6. [6]
    Versions and Constraints
    (getcomposer.org)
  7. [7]
    Autoloader Optimization
    (getcomposer.org)
  8. [8]
  9. [9]
    Dependabot Malware Alerts
    (docs.github.com)
  10. [10]