Composer — Dependency Resolution and Production Optimization
Go beyond composer.json basics into how Composer actually resolves versions, use path and VCS repositories for local and git-sourced packages, understand autoload-dev, classmap/files autoloading, the optimized/authoritative autoloader for production, handle dependency conflicts, and use composer audit and reproducible builds for security and consistency.
The earlier Composer lesson covered the everyday commands — require, install, update, composer.json, composer.lock — which is enough to add and update packages day to day. What it doesn't cover is what happens under the hood when 'composer update' has to pick specific versions out of a huge dependency graph, how to point Composer at a package that isn't on Packagist at all (your own local package, or a fork living only in a git repo), and the production-specific settings (optimized autoloading, composer audit) that matter once an application is more than a personal project. This lesson picks up exactly where that one left off.
Semantic versioning in depth
Composer's version constraints are built entirely on semantic versioning (semver): a version is MAJOR.MINOR.PATCH, where PATCH increments for backward-compatible bug fixes, MINOR increments for backward-compatible new functionality, and MAJOR increments for breaking changes. This convention is what makes Composer's constraint operators meaningful rather than arbitrary: the caret operator ^2.3.1 means 'anything compatible with 2.3.1 under semver rules' — allow updates that semver promises won't break your code, which for a version at MAJOR ≥ 1 means anything >=2.3.1 <3.0.0 (MINOR and PATCH bumps allowed, MAJOR bumps excluded because those are where breaking changes are permitted). The tilde operator ~2.3.1 is stricter: it only allows the rightmost specified component to increment, so ~2.3.1 means >=2.3.1 <2.4.0 (patch-level only), while ~2.3 means >=2.3.0 <3.0.0 (the same as caret would give at that specificity) — the tilde's behavior actually shifts depending on how many version components you specify.
A quirk worth knowing: for a 0.x.y version (pre-1.0, where semver explicitly says anything can break between minor versions), ^0.3.1 narrows to >=0.3.1 <0.4.0 rather than <1.0.0 — Composer treats the leading zero as signaling instability and ties the caret's upper bound to the first non-zero component instead of always the major version, matching semver's own pre-1.0 instability convention.
How dependency resolution actually works
When you run composer require or composer update, Composer isn't just picking the newest version of the package you named — it has to find a single set of versions, one per package, that simultaneously satisfies every constraint from every package in the entire tree (your direct requirements, plus every transitive dependency's own constraints on shared packages). This is formally a Boolean satisfiability problem, and Composer's resolver (built on a SAT solver, following the same general approach as tools like apt's dependency resolver) works through it by translating every version constraint into logical clauses and searching for a satisfying assignment, backtracking when a tentative choice turns out to conflict with a constraint discovered deeper in the graph.
This is also exactly why composer.lock exists and matters: solving that SAT problem can have many valid solutions (multiple version combinations that all satisfy every constraint), and which one the solver picks can depend on solve order and even Composer's own version. The lock file freezes the *exact* resolution that was found once, so every subsequent composer install (as opposed to update) reproduces that exact set of versions rather than potentially re-solving to a different, equally-valid-but-different combination.
Path repositories: local package development
When developing a package alongside the application that consumes it — a shared internal library still being actively edited, for instance — you don't want to publish a new version to a registry for every change just to test it. A path repository points Composer at a local directory instead: {"repositories": [{"type": "path", "url": "../my-shared-library"}]} in composer.json, then composer require acme/shared-library:* picks it up from that path. By default Composer symlinks the local directory into vendor/ rather than copying it, so edits in the source directory are immediately visible to the consuming application without re-running install — exactly what you want during active co-development.
VCS repositories: installing directly from a git URL
Sometimes the package you need isn't on Packagist at all — a private fork with an unmerged fix, or an internal package that will never be published publicly. A VCS repository tells Composer to read a git repository directly and treat its tags/branches as available versions: {"repositories": [{"type": "vcs", "url": "https://github.com/yourorg/forked-package.git"}]}, then composer require yourorg/forked-package:dev-main (for a branch) or a tag-based constraint for a tagged release. Composer reads that repository's own composer.json to learn the package name and its own dependencies, exactly as it would for a Packagist-hosted package — from the resolver's point of view a VCS-sourced package behaves identically to any other once discovered.
Autoload-dev, classmap, and files autoloading
The autoload-dev section mirrors autoload but only applies during development — most commonly used for a test suite's namespace: {"autoload-dev": {"psr-4": {"Tests\\": "tests/"}}} registers Tests\ for local development and CI, but that mapping is deliberately excluded when the package is installed as a dependency by someone else (composer install --no-dev in production skips it entirely), since consumers of your package have no need for your test namespace.
PSR-4 assumes a strict namespace-to-directory convention, but not every codebase (especially older ones, or vendored third-party code) follows it. classmap autoloading instead scans specified directories/files at dump-time, parses every class/interface/trait declaration it finds, and builds a direct class-name-to-file-path map regardless of namespace structure — useful for legacy code with inconsistent directory layout. files autoloading is different again: it's for including files that don't declare a class at all, most commonly a file of global helper functions — every file listed under files is simply required unconditionally on every request, since there's no class name to autoload against.
• psr-4 / psr-0: class name maps predictably to a file path by convention — the default, preferred choice for anything you control. • classmap: scans and indexes files regardless of naming convention — for legacy code that doesn't follow PSR-4. • files: unconditionally required on every load — for global helper functions with no class to autoload.
The optimized/authoritative autoloader for production
By default, Composer's PSR-4 autoloader resolves a class name to a file path at runtime by checking whether the expected file exists — cheap, but it's still a filesystem stat call on every previously-unseen class in a request. composer install --optimize-autoloader (or -o) pre-generates a full classmap covering every PSR-4/PSR-0 mapped class at install time, so the runtime autoloader looks the class up directly in an array instead of computing and checking a file path — meaningfully faster at scale, with the tradeoff that the map must be regenerated (by re-running the optimize step) whenever classes are added, removed, or renamed.
composer install --classmap-authoritative (or -a, which implies optimization) goes one step further: it tells the autoloader that the generated classmap is the complete, authoritative source of truth, so if a class isn't found in the map, the autoloader fails immediately rather than falling back to a filesystem check. This is faster still for the 'class doesn't exist / not found' path, but is only safe for production deploys where you can guarantee the classmap was regenerated from the exact code being deployed — never use it in local development, where new classes get added between classmap regenerations routinely.
Dependency conflict resolution, plugins, and private repositories
When the resolver genuinely cannot find any version combination satisfying every constraint (Package A requires lib:^2.0, Package B requires lib:^1.0, and no single lib version satisfies both), Composer reports which constraints conflict rather than silently picking one and breaking the other. The practical resolutions are: widen one package's constraint if you control it (patch A or B's composer.json), wait for an upstream release that widens the constraint, use a replace or conflict directive if you understand the actual compatibility well enough to override Composer's conservative default, or, if two packages truly require incompatible major versions of the same library and neither can change, accept that they cannot coexist in the same project and pick one.
Composer plugins are packages of type composer-plugin that hook into Composer's own event system (install, update, autoload-dump events) to extend Composer's behavior itself — for example, a plugin that automatically copies certain files after install, or one that customizes how a specific package type gets installed (WordPress plugin/theme installers are a common real-world example, since WordPress's directory conventions don't match a typical PSR-4 package layout). For organizations that can't or won't publish internal packages publicly, private package repositories (Private Packagist, or a self-hosted Satis instance which statically generates a Packagist-compatible package index) provide the same require-by-name workflow as public Packagist but authenticated and access-controlled to the organization's own packages.
composer audit and reproducible builds
composer audit checks every package in your current lock file's resolution against a database of known security advisories (backed by the FriendsOfPHP/security-advisories database) and reports any installed version with a known vulnerability, including its severity and advisory link — it's a cheap, fast check worth running in CI on every build, since a dependency can develop a known vulnerability after you first installed it even if your own code never changed. This is distinct from composer outdated, which just reports newer versions exist; audit specifically flags security-relevant ones.
Reproducible builds are the property that installing from the same composer.lock produces the exact same installed code every time, on any machine, at any later date — this is precisely what the lock file's frozen SAT-solver resolution guarantees, provided composer install (never composer update) is what CI and production deploys run. Combined with composer install --no-dev (skip dev-only dependencies including their autoload-dev mappings) and the optimized/authoritative autoloader, this is the standard production install invocation: composer install --no-dev --optimize-autoloader --classmap-authoritative, run against a committed lock file, on every deploy.
composer.json: path/VCS repositories, autoload-dev, and multiple autoload strategies
Production install: composer install --no-dev --optimize-autoloader --classmap-authoritative against a committed composer.lock. composer audit should run in CI on every build, not just occasionally — a dependency can gain a known CVE after you first installed it.
{
"repositories": [
{ "type": "path", "url": "../my-shared-library" },
{ "type": "vcs", "url": "https://github.com/yourorg/forked-package.git" }
],
"autoload": {
"psr-4": { "App\\": "src/" },
"classmap": ["legacy/"],
"files": ["src/helpers.php"]
},
"autoload-dev": {
"psr-4": { "Tests\\": "tests/" }
},
"require": {
"acme/shared-library": "*",
"yourorg/forked-package": "dev-main"
}
}Code Examples
With symlink: true (the default), Composer creates vendor/acme/shared as a symlink to ../packages/acme-shared rather than copying files, so a code change in the local package directory is picked up immediately by the consuming app without re-running composer install — the standard setup for developing an internal library alongside the app that uses it. The @dev constraint accepts the package regardless of any tagged version, appropriate for a path-repository package that's still under active local development and may have no tags at all yet.
"type": "path", This is a documentation-as-code example: the meaningful content is the CI command itself and the reasoning for each flag, not runtime PHP logic — it's included as executable PHP purely to show where in the deploy pipeline (before vendor/autoload.php is ever required) this optimization takes effect. Expected result: Class autoloading in production resolves via array lookup instead of per-class filesystem stat calls.
{
"name": "acme/web-app",
"repositories": [
{
"type": "path",
"url": "../packages/acme-shared",
"options": { "symlink": true }
}
],
"require": {
"acme/shared": "@dev"
}
}<?php
// Run in CI/CD, never on a developer machine mid-work:
//
// composer install --no-dev --optimize-autoloader --classmap-authoritative
//
// Effects, in order:
// 1. --no-dev : skip require-dev packages AND autoload-dev mappings entirely
// 2. --optimize-autoloader : pre-generate a full classmap for O(1) class lookups
// 3. --classmap-authoritative : treat that classmap as complete — no filesystem
// fallback check if a class isn't found in it
//
// The generated vendor/composer/autoload_classmap.php is regenerated fresh on
// EVERY deploy from the exact code being shipped, so staleness is never an issue
// in this pipeline — it would only be a risk if this flag were used locally
// between ad-hoc edits without re-running the command.
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
// From here on, every class lookup is a direct array hit in the generated
// classmap rather than a runtime file-existence check.Why composer audit became a standard CI step
Supply-chain vulnerabilities in transitive dependencies — a package three levels deep in your dependency tree gaining a disclosed CVE — are now one of the most common real-world PHP security incidents, precisely because they can affect an application whose own code never changed. Running composer audit on every CI build (and treating a high-severity finding as a build failure, not just a warning) catches this class of risk automatically, which is why it's considered as close to mandatory as composer install --no-dev itself in a modern production pipeline.
// CI/CD pipeline: composer update && composer install --no-dev
// CI/CD pipeline: composer install --no-dev --optimize-autoloader --classmap-authoritative (against a committed composer.lock, never composer update in CI)
Running composer update in CI re-solves the dependency graph and can pick a different, newer-but-equally-constraint-satisfying set of versions than what was actually tested — defeating the entire purpose of committing composer.lock. CI and production should only ever run composer install, which reproduces the exact locked resolution; update belongs in a developer's local workflow (or a deliberate, reviewed 'bump dependencies' commit), never in an automated deploy.
Why does composer.lock matter beyond simply recording which versions were installed? A. It stores the source code of every dependency to avoid re-downloading B. Dependency resolution is a SAT problem that can have multiple valid solutions, and the lock file freezes the exact one that was found so composer install reproduces it exactly rather than potentially re-solving to a different combination C. It is only used to speed up composer update D. It replaces the need for a composer.json file
B. Dependency resolution is a SAT problem that can have multiple valid solutions, and the lock file freezes the exact one that was found so composer install reproduces it exactly rather than potentially re-solving to a different combination — Because multiple version combinations can all satisfy the same set of constraints, the lock file's job is to freeze the specific resolution that was actually found and tested, so every future composer install reproduces that exact set of versions instead of leaving the outcome to a fresh, potentially different solve.
What does --classmap-authoritative change about Composer's autoloader compared to --optimize-autoloader alone? A. It disables PSR-4 autoloading entirely B. It treats the generated classmap as complete, so a class not found in it fails immediately instead of falling back to a filesystem check C. It automatically runs composer audit before every request D. It enables path repositories
B. It treats the generated classmap as complete, so a class not found in it fails immediately instead of falling back to a filesystem check — --optimize-autoloader alone still falls back to a filesystem check for classes outside the pre-generated map (e.g. non-PSR-4 classmap-scanned code discovered late); --classmap-authoritative declares the map complete and skips that fallback entirely, which is faster but only safe when the map is guaranteed freshly regenerated from the exact deployed code.
What is the main purpose of a path repository in composer.json? A. To install a package directly from a git URL instead of Packagist B. To point Composer at a local directory (often symlinked into vendor/) for developing a package alongside the application that consumes it, without publishing a new version for every change C. To specify which PHP version path the project requires D. To configure the optimized autoloader's output directory
B. To point Composer at a local directory (often symlinked into vendor/) for developing a package alongside the application that consumes it, without publishing a new version for every change — A path repository lets Composer install directly from a local directory (symlinked by default), so edits to a package still under active development are immediately visible to the consuming application without publishing and re-requiring a new version for every change.
You maintain an internal library still under active development, consumed by two separate applications in your organization, and it will never be published to public Packagist. Describe the repository configuration each consuming application's composer.json would need, and explain what changes about that configuration once the library stabilizes and moves to a private Packagist-compatible registry instead of being edited alongside the apps. Hint: Local/active development points to a path repository (immediate local edits visible via symlink); once stabilized and versioned, a VCS repository (pointing at its git remote) or a private registry entry replaces the path entry, and the constraint moves from @dev/* to a real semver constraint against tagged releases.
Try it yourself — there's no single right answer here; compare your result against this lesson's examples.