Composer and Packages in Depth
Semantic versioning, the anatomy of composer.json, classmap/files autoloading, and publishing your own reusable package.
Semantic versioning
Composer's whole ecosystem depends on packages declaring version numbers that mean something predictable — semantic versioning (semver) is the convention: MAJOR.MINOR.PATCH, e.g. 2.4.1.
| Segment | Bumped when... | Should this break existing code? |
|---|---|---|
MAJOR |
A backwards-incompatible change is made | Yes — consumers must expect to update their own code |
MINOR |
A new, backwards-compatible feature is added | No — existing code keeps working unchanged |
PATCH |
A backwards-compatible bug fix is made | No — existing code keeps working unchanged |
A composer.json doesn't usually pin an exact version — it specifies a constraint, letting Composer pick the newest version satisfying it whenever you run composer update:
| Constraint | Meaning |
|---|---|
2.4.1 |
Exactly this version — rare, since it blocks even safe patch updates |
^2.4.1 |
>=2.4.1 <3.0.0 — allow any backwards-compatible update, per semver's promise |
~2.4.1 |
>=2.4.1 <2.5.0 — allow only patch-level updates |
>=2.4 |
Any version from 2.4 onward, with no upper bound at all |
2.4.* |
Any 2.4.x patch release |
^ is the constraint you'll reach for by far the most often in practice — it trusts a package's semver promise to allow new features and fixes in automatically, while still refusing to silently pull in a major version that could break your code. This entire system only works if package maintainers actually honor semver's contract — a maintainer who ships a breaking change in a minor release undermines every consumer's ^ constraint, which is exactly why semver discipline is treated as a serious commitment in the PHP package ecosystem (and reinforced by Packagist, the default package registry, surfacing version history publicly).
Anatomy of composer.json
{
"name": "acme/invoice-tools",
"description": "Invoice generation utilities for Acme's internal apps",
"type": "library",
"require": {
"php": "^8.2",
"acme/currency": "^1.3"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"Acme\\InvoiceTools\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Acme\\InvoiceTools\\Tests\\": "tests/"
}
},
"minimum-stability": "stable",
"license": "MIT"
}
require— runtime dependencies, needed wherever this package actually runs, including in production.require-dev— dependencies needed only for development (test frameworks, static analysis tools) — never installed when a consumer runscomposer install --no-dev, which is exactly what a production deployment should do.autoload/autoload-dev— how Composer's generated autoloader maps namespaces to directories (see below) for the package's own code versus its test code."php": "^8.2"insiderequireis itself a version constraint — it tells Composer (and anyone installing the package) the minimum PHP version this code actually needs, and Composer refuses to install it on an incompatible PHP version.minimum-stability— how tolerant Composer is of unstable-tagged dependency versions (dev,alpha,beta,RC,stable);stableis the safe default for anything beyond active experimentation.
composer.lock, generated alongside composer.json, records the exact resolved version of every dependency (and every dependency's own dependencies) actually installed — this is what makes composer install fully reproducible across machines and deployments, in contrast to composer update, which re-resolves constraints against whatever the latest matching versions currently are. Committing composer.lock to version control (standard practice for an application, though not for a reusable library, which should let its own consumers resolve versions themselves) is what guarantees every developer and every deployment installs identical dependency versions.
Autoloading strategies beyond PSR-4
PSR-4 (covered on the modern-php page) handles the overwhelmingly common case — one class per file, directory structure mirroring the namespace — but Composer supports a few other strategies for cases that don't fit that mold:
{
"autoload": {
"psr-4": {
"Acme\\InvoiceTools\\": "src/"
},
"classmap": [
"legacy/"
],
"files": [
"src/helpers.php"
]
}
}
classmap— Composer scans the given directories, indexing every class it finds regardless of file or directory naming, and builds a direct class-name-to-file-path map. This is the standard escape hatch for legacy code that predates PSR-4 (a directory full of files with class names that don't match PSR-4's directory-mirrors-namespace convention) — you point Composer at the directory, and it figures out the mapping itself rather than requiring the files to be reorganized.files— a list of specific files torequireunconditionally on every request, regardless of whether any class inside them is actually used. This is for genuinely global, non-class code — a set of standalone helper functions, for instance — since PSR-4 autoloading only ever triggers when a class is actually referenced, never for a bare function.
<?php
// src/helpers.php — loaded unconditionally via the "files" entry above
function formatCurrency(float $amount, string $currency = 'USD'): string {
return match ($currency) {
'USD' => '$' . number_format($amount, 2),
'EUR' => number_format($amount, 2) . ' €',
default => number_format($amount, 2) . ' ' . $currency,
};
}
Run composer dump-autoload after changing any of these mappings — Composer builds the actual autoloader files once, ahead of time, rather than resolving each mapping dynamically on every request (a meaningful performance difference at scale), which means the generated autoloader can go stale if you edit composer.json's autoload section without regenerating it.
Creating your own reusable package
Turning a piece of code into a package other projects (or other teams) can depend on via Composer, conceptually, comes down to a few concrete steps:
- Structure the code as a standalone library — no assumptions baked in about a specific application's directory layout, database, or framework; a clean
src/directory with a clear public API. - Write a
composer.jsondeclaring the package's own name (vendor/package-nameformat), its dependencies, and its PSR-4 autoload mapping, as shown above. - Version it with git tags following semver —
git tag v1.0.0,git tag v1.1.0, and so on; Composer (and Packagist, if published publicly) read these tags directly as the package's available versions. - Publish it — either to Packagist (the default, public registry Composer checks automatically, free for open-source packages), or to a private repository registered directly in a consuming project's
composer.json(a"repositories"entry pointing at a private VCS or a self-hosted Composer repository like Private Packagist or Satis) for internal, closed-source packages.
{
"repositories": [
{ "type": "vcs", "url": "https://github.com/acme/invoice-tools" }
],
"require": {
"acme/invoice-tools": "^1.0"
}
}
Once published, a consuming project's composer require acme/invoice-tools resolves and installs it exactly like any third-party package — there's no meaningful distinction, from Composer's point of view, between "a package your own organization wrote" and "a package a stranger on Packagist wrote"; both are just a name, a version constraint, and a source Composer knows how to fetch from.
Common mistakes
- Committing
composer.lockfor a reusable library rather than an application — a library should generally let each consuming project resolve its own compatible dependency versions, rather than forcing one specific locked set on everyone who depends on it. - Editing
composer.json'sautoloadsection and forgetting to runcomposer dump-autoloadafterward, then being confused why a class "still can't be found" despite the mapping looking correct. - Reaching for an overly loose constraint like
*or a bare>=1.0with no upper bound, which can silently pull in a future breaking major version and defeats the entire purpose of semantic versioning. - Putting a testing or debugging tool (like PHPUnit) under
requireinstead ofrequire-dev, which then gets installed in production unnecessarily every timecomposer installruns there.
Interview questions
Q: What's the practical difference between ^2.4.1 and ~2.4.1 as a version constraint?
^2.4.1 allows any version considered backwards-compatible under semver rules — effectively >=2.4.1 <3.0.0, so new minor features and patches are picked up automatically. ~2.4.1 is narrower — >=2.4.1 <2.5.0 — allowing only patch-level updates within the same minor version. ^ is the far more common everyday choice; ~ is reached for when you want to be more conservative and only accept bug fixes, not new features, from a given dependency.
Q: Why does composer.lock matter, and why would you commit it for an application but typically not for a library you're publishing?
composer.lock records the exact resolved version of every dependency (direct and transitive) that was actually installed, which is what makes composer install fully reproducible across every machine and deployment that uses it — without it, two installs run days apart could resolve different versions even from an identical composer.json. An application should commit it so every environment gets identical dependencies. A library, by contrast, is meant to be installed inside other projects with their own dependency trees — locking its own dependencies to one exact set would fight against the consuming project's ability to resolve compatible versions across all of its dependencies together.