Composer: PHP's Dependency Manager
Understand what Composer is and does, how composer.json and composer.lock work together, what the vendor directory contains, how install and update differ, how to require/remove packages and dev dependencies, how to define scripts and repositories, how platform requirements work, and how Packagist fits into the ecosystem.
Composer is the standard dependency manager for PHP. Almost nothing in the modern PHP ecosystem — Symfony, Laravel, PHPUnit, Monolog, Guzzle — is installed by hand; instead you declare what your project needs in a single file, composer.json, and Composer resolves a compatible set of package versions, downloads them into a vendor/ directory, and generates an autoloader so use statements just work. Composer also standardizes how PHP libraries publish themselves, which is what makes the entire Packagist ecosystem interoperable in the first place.
composer.json: the project manifest
composer.json is the single source of truth for a PHP project's dependencies and metadata. It lists the package name, description, license, required packages (under require), development-only packages (under require-dev), autoloading rules (under autoload), and optional configuration like scripts and repositories. It is meant to be committed to version control and hand-edited (or edited via composer require), and it describes intent: 'this project needs guzzlehttp/guzzle version ^7.0' rather than a specific resolved version.
A minimal application composer.json might declare "require": { "php": ">=8.1", "monolog/monolog": "^3.0" }. Composer reads this, consults each package's own composer.json to find its dependencies, and recursively resolves an entire dependency graph that satisfies every constraint simultaneously — this resolution step is what makes Composer a real dependency solver rather than a simple file downloader.
composer.lock: the exact, reproducible snapshot
composer.json describes acceptable ranges; composer.lock records the exact versions that were actually resolved and installed the last time composer install or composer update ran — down to the exact commit hash for each package. This file should also be committed to version control (for applications; library packages are the one common exception, discussed later), because it is what guarantees every developer, CI runner, and production server installs the identical set of package versions, byte for byte, rather than whatever happens to satisfy the constraints on that particular day.
Without a committed lock file, two machines running composer install on the same composer.json could legitimately end up with different minor/patch versions of a dependency if new releases were published between installs — a common source of 'works on my machine' bugs that composer.lock exists specifically to eliminate.
vendor/, install vs update
The vendor/ directory is where Composer actually places downloaded package source code, plus the generated autoloader (vendor/autoload.php) that every PHP entry point includes to make use SomePackage\SomeClass; resolve correctly. vendor/ is derived, regenerable output — it is never committed to version control (it belongs in .gitignore) because it can always be rebuilt from composer.json and composer.lock.
composer install reads composer.lock (if present) and installs exactly those locked versions, ignoring composer.json's ranges entirely for resolution purposes — this is the command you run when checking out an existing project or deploying to production, because it reproduces a known-good state. composer update ignores the lock file, re-resolves every dependency against the ranges in composer.json to find the newest versions that still satisfy them, installs those, and rewrites composer.lock to match. composer update vendor/package-name updates just that one package (and its dependents as needed) rather than the whole tree, which is the safer, more targeted way to pick up a single upgrade.
• composer install → use the lock file, reproducible, safe for CI/deploy • composer update → ignore the lock file, re-resolve to newest allowed versions, rewrites the lock file • composer update vendor/package → re-resolve just one package instead of the whole tree • vendor/ is generated output — never commit it, always .gitignore it
require, remove, dev dependencies, and scripts
composer require vendor/package adds a new dependency: it edits composer.json, resolves and downloads it, and updates composer.lock, all in one step — this is the normal way to add a package, far more reliable than hand-editing composer.json and hoping the constraint syntax is right. composer require --dev vendor/package does the same but places the entry under require-dev instead of require, marking it as needed only for development and testing (PHPUnit, PHPStan, a coding-standard fixer) — these are excluded when a package is installed as someone else's dependency via composer install --no-dev, which is exactly what a production deploy should run. composer remove vendor/package reverses require, removing the entry, deleting it from vendor/, and updating the lock file.
The scripts section of composer.json lets a project define named shell commands or PHP callbacks runnable via composer run-script <name> (or a shorthand composer <name> for common cases), and also hooks into Composer's own lifecycle events — post-install-cmd, post-update-cmd, and others fire automatically after the corresponding Composer command finishes, which is how projects auto-run things like cache-clearing or asset-building right after composer install.
Repositories, platform requirements, and Packagist
By default Composer looks up every package on Packagist (packagist.org), the default, public package repository for the PHP ecosystem — the PHP equivalent of npm's registry. The repositories key in composer.json lets a project add additional sources: a private VCS repository (a Git URL for an unpublished internal package), a private Packagist-compatible registry, or a path repository pointing at a local directory during development, useful for developing a library and an application that consumes it side by side without publishing every intermediate change.
The require section can also list 'platform' requirements — not real Composer packages but virtual entries representing the runtime environment itself: "php": ">=8.1" requires a minimum PHP version, and entries like "ext-json": "*" or "ext-pdo": "*" require specific PHP extensions to be loaded. Composer checks these against the actual environment it's running in and refuses to install if they aren't met, which surfaces environment mismatches at install time rather than as a confusing runtime fatal error deep inside application code.
Common Composer commands and a minimal composer.json
composer install --no-dev is the command a production deploy pipeline should run — it installs only require, never require-dev, so testing/tooling packages never ship to production. The generated vendor/autoload.php must be included once, typically at the very top of your application's entry point, before any of your own namespaced classes are used.
// A minimal application composer.json
{
"name": "acme/blog-app",
"require": {
"php": ">=8.1",
"monolog/monolog": "^3.0",
"ext-json": "*"
},
"require-dev": {
"phpunit/phpunit": "^10.0"
},
"autoload": {
"psr-4": { "App\\": "src/" }
},
"scripts": {
"test": "phpunit",
"post-install-cmd": "php bin/cache-clear.php"
}
}
// Command line
composer install // install exactly what composer.lock records
composer update // re-resolve everything, rewrite composer.lock
composer update monolog/monolog // re-resolve just this package
composer require guzzlehttp/guzzle // add a runtime dependency
composer require --dev phpunit/phpunit // add a dev-only dependency
composer remove monolog/monolog // remove a dependency
composer run-script test // run the "test" script
composer install --no-dev // production install, skips require-devCode Examples
composer init interactively (or, with -n, non-interactively) scaffolds a fresh composer.json. composer require then adds monolog/monolog, resolving a compatible version, downloading it into vendor/, and writing composer.lock. composer install re-running afterward is a no-op here since the lock file already matches, demonstrating the reproducibility guarantee. Expected result: Using version ^3.x for monolog/monolog ./composer.json has been created Generating autoload files
Including vendor/autoload.php once wires up Composer's generated PSR-4 autoloader, so use Monolog\Logger; resolves correctly without any manual require for that specific class file — Composer already mapped the Monolog\ namespace to its source directory inside vendor/monolog/monolog/src based on Monolog's own PSR-4 declaration.
This project pulls a private internal library over Git (a VCS repository, not on Packagist) and also wires in a locally-developed package via a path repository for fast iterative development. The post-update-cmd array chains two lifecycle-hooked commands that automatically run every time composer update finishes, keeping generated caches and public assets in sync with dependency changes without a manual step.
"type": "path", "url": "../local-experiment"
# Starting a brand-new project and adding a first dependency
composer init --name=acme/hello-app --require=php:'>=8.1' -n
composer require monolog/monolog
composer install<?php
// public/index.php — the standard Composer autoload bootstrap
require __DIR__ . '/../vendor/autoload.php';
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
$logger = new Logger('app');
$logger->pushHandler(new StreamHandler(__DIR__ . '/../var/app.log'));
$logger->info('Application booted');{
"name": "acme/blog-app",
"require": {
"php": ">=8.1",
"acme/shared-lib": "^2.0"
},
"repositories": [
{
"type": "vcs",
"url": "https://git.internal.example.com/acme/shared-lib.git"
},
{
"type": "path",
"url": "../local-experiment"
}
],
"scripts": {
"post-update-cmd": [
"@php bin/console cache:clear",
"@php bin/console assets:install"
]
}
}Composer's resolve-and-install flow
composer.json expresses intent; composer.lock freezes the resolved result; vendor/ is disposable output rebuilt from the lock file.
composer.json (ranges)
|
v
composer update -> resolve dependency graph against Packagist/repositories
|
v
composer.lock (exact versions, committed)
|
v
composer install (on any machine) -> reads lock, not composer.json ranges
|
v
vendor/ (downloaded source + autoload.php, gitignored, regenerable)Important Rules
composer.json declares acceptable version ranges; composer.lock freezes the exact resolved versions — commit both for applications.
Never commit vendor/ — it is fully regenerable from composer.json + composer.lock via composer install.
Use composer install (reads the lock file) for reproducible environments like CI and production; use composer update only when you intentionally want newer versions.
Put testing/tooling-only packages in require-dev, and deploy production with composer install --no-dev.
Platform requirements (php version, ext-* entries) make environment mismatches fail loudly at install time instead of as a confusing runtime error.
Where this shows up
Every modern PHP framework — Symfony, Laravel, Slim, Laminas — is installed and extended via Composer, and CI pipelines universally run composer install --no-dev --optimize-autoloader before running tests or building a deploy artifact, relying entirely on composer.lock to guarantee the CI environment matches what a developer tested locally. Packagist itself is maintained collaboratively and mirrors package metadata from GitHub/GitLab tags, meaning publishing a library is as simple as tagging a Git release and submitting the repository URL once.
This is backwards from the correct convention: vendor/ is large, regenerable, and platform-dependent (some packages ship compiled/platform-specific pieces), so it belongs in .gitignore, while composer.lock is small, text-based, and is exactly what guarantees every environment installs identical dependency versions — it should always be committed for an application.
[multiple-choice] What is the key difference between composer install and composer update? A. They are interchangeable aliases for the same operation B. install reads composer.lock and installs exactly those versions; update re-resolves against composer.json's ranges and rewrites the lock file C. install only works for the first setup of a project, update is for every time after that D. update deletes vendor/ first, install never does
B. install reads composer.lock and installs exactly those versions; update re-resolves against composer.json's ranges and rewrites the lock file — composer install reproduces the exact locked state, which is what makes builds reproducible across machines; composer update deliberately re-resolves dependencies against composer.json's ranges to pick up newer compatible versions, then rewrites composer.lock to match the new result.
[multiple-choice] Why should composer.lock be committed to version control for an application, while vendor/ should not? A. composer.lock is small and vendor/ is always huge, that's the only reason B. composer.lock records the exact resolved versions for reproducibility; vendor/ is fully regenerable output derived from composer.json and composer.lock C. Composer requires composer.lock to be committed or it refuses to run D. vendor/ contains secrets that should never be shared
B. composer.lock records the exact resolved versions for reproducibility; vendor/ is fully regenerable output derived from composer.json and composer.lock — composer.lock is the reproducibility guarantee — every environment that runs composer install against the same lock file gets identical dependency versions. vendor/ is just the materialized result of that resolution and can always be regenerated, so committing it adds repository bloat without adding information.
Starting from an empty directory, use Composer commands (not hand-edited JSON) to: create a new project named yourname/practice-app requiring PHP >=8.1, add guzzlehttp/guzzle as a runtime dependency, add phpunit/phpunit as a dev dependency, and then run an install that would be appropriate for a production deploy.
composer init --name=yourname/practice-app --require=php:'>=8.1' -n composer require guzzlehttp/guzzle composer require --dev phpunit/phpunit composer install --no-dev
You'll need composer init, two composer require calls (one with --dev), and composer install --no-dev at the end.