PHP Composer and PSR-4 Autoloading Complete Guide
A practical guide to Composer — how PSR-4 maps namespaces onto directories, what the lock file actually guarantees, how version constraints behave, and which commands belong in a deployment pipeline.
Composer solves two problems at once, and conflating them causes most of the confusion around it. It is a dependency manager, resolving and installing packages, and it is an autoloader generator, producing the code that maps class names to files so you never write require again.
Understanding that split explains otherwise puzzling behaviour — such as why adding a namespace to composer.json does nothing until you run dump-autoload.
Mapping Namespace Prefixes to Directories
PSR-4 is a convention: a namespace prefix corresponds to a base directory, and the remainder of the fully-qualified class name corresponds to a path beneath it, with one class per file.
{
"name": "acme/shop",
"type": "project",
"require": {
"php": ">=8.2",
"monolog/monolog": "^3.5"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"Acme\\Shop\\": "src/"
},
"files": [
"src/helpers.php"
]
},
"autoload-dev": {
"psr-4": {
"Acme\\Shop\\Tests\\": "tests/"
}
}
}
With that mapping, the class-to-file correspondence is mechanical:
| Fully-qualified class name | File path |
|---|---|
Acme\Shop\Order | src/Order.php |
Acme\Shop\Billing\Invoice | src/Billing/Invoice.php |
Acme\Shop\Http\Controller\Cart | src/Http/Controller/Cart.php |
Acme\Shop\Tests\OrderTest | tests/OrderTest.php |
<?php
declare(strict_types=1);
namespace Acme\Shop\Billing;
use Acme\Shop\Order;
use Monolog\Logger;
final class Invoice
{
public function __construct(
private readonly Order $order,
private readonly Logger $logger,
) {}
public function total(): int
{
return array_sum(
array_map(fn(array $line) => $line['price_cents'], $this->order->lines())
);
}
}
<?php
// The only require your application needs
require __DIR__ . '/../vendor/autoload.php';
use Acme\Shop\Billing\Invoice;
// Found and loaded automatically on first use
$invoice = new Invoice($order, $logger);
\\ matters. The most common PSR-4 failure is a directory named controllers while the namespace says Controllers — it works on case-insensitive Windows and breaks on a Linux server.Constraints Versus Resolved Versions
composer.json records what you asked for — a range of acceptable versions. composer.lock records what was resolved — the exact version and commit of every package, including transitive dependencies.
| composer.json | composer.lock | |
|---|---|---|
| Contents | Version constraints | Exact resolved versions |
| Edited by | You | Composer only |
| Commit to git | Yes | Yes — always |
| Changed by | require, remove | update, require, remove |
composer.lock for applications without exception — it is what makes a deployment reproducible. Libraries intentionally omit it, because the consuming application resolves the versions.# Installs the EXACT versions in composer.lock.
# Deterministic — use this in CI and on deploy.
composer install
# Re-resolves constraints to the newest allowed versions
# and REWRITES composer.lock. A development action only.
composer update
# Update one package rather than everything
composer update monolog/monolog
# Add a dependency: updates both json and lock
composer require guzzlehttp/guzzle
# A dev-only dependency
composer require --dev phpunit/phpunit
# Check what is outdated without changing anything
composer outdated --direct
Running composer update on a production server is the classic mistake. It ignores the lock file, pulls whatever versions satisfy the constraints today, and can deploy code that was never tested.
What the Operators Actually Permit
Composer follows semantic versioning, where MAJOR.MINOR.PATCH signals breaking changes, new features, and fixes respectively. The constraint operators express how much drift you accept.
| Constraint | Allows | Blocks |
|---|---|---|
^3.5 | 3.5 up to < 4.0 | 4.0 (breaking) |
~3.5 | 3.5 up to < 3.6 | 3.6 (new features) |
~3.5.2 | 3.5.2 up to < 3.6.0 | 3.6.0 |
3.5.* | Any 3.5.x | 3.6.0 |
3.5.2 | Exactly 3.5.2 | Everything else |
>=3.5 <4.0 | Explicit range | Outside the range |
The caret ^ is the right default for almost everything: it accepts fixes and features while refusing major-version upgrades that may break your code. Pinning an exact version should be reserved for a package you know is unreliable across patches.
^0.3.1 therefore allows 0.3.x but not 0.4.0, because pre-1.0 packages are expected to break between minors.Automating Checks and Optimising the Autoloader
Composer can define named scripts, which gives a project a consistent command surface regardless of which tools it uses underneath.
{
"scripts": {
"test": "phpunit --colors=always",
"lint": "php-cs-fixer fix --dry-run --diff",
"analyse": "phpstan analyse src tests --level=8",
"check": [
"@lint",
"@analyse",
"@test"
],
"post-install-cmd": "@php bin/console cache:clear"
},
"config": {
"sort-packages": true,
"optimize-autoloader": true,
"platform": {
"php": "8.2.0"
}
}
}
The platform block is underused and valuable: it makes Composer resolve against the PHP version production actually runs, so a developer on a newer PHP cannot accidentally lock a package that the server cannot install.
# Run scripts defined in composer.json
composer test
composer check
# The production install:
# --no-dev skip require-dev packages
# --optimize-... convert PSR-4 rules into a static classmap
# --classmap-... fail if a class is missing rather than falling back
# --no-interaction safe for CI
composer install \
--no-dev \
--optimize-autoloader \
--classmap-authoritative \
--no-interaction \
--prefer-dist
# Regenerate the autoloader after changing the autoload section
# or adding a class — composer.json changes alone are not enough
composer dump-autoload --optimize
# Verify the lock file matches composer.json (good CI gate)
composer validate --strict
# Report known vulnerabilities in installed versions
composer audit
Without --optimize-autoloader, every class load performs filesystem checks to find the file. The optimised autoloader replaces that with a pre-built class-to-path map, which measurably reduces per-request overhead.
--classmap-authoritative makes the generated classmap the only source of truth — classes not in it will not be found. That is a feature in production and a problem in development, where new files appear constantly.• Commit composer.lock for applications; use composer install on deploy, never update.
• Prefer ^ constraints; remember pre-1.0 packages break at the minor version.
• Run dump-autoload after changing the autoload section.
• Deploy with --no-dev --optimize-autoloader, and add composer audit to CI.
Summary
Composer is a dependency resolver and an autoloader generator, and most of its surprising behaviour follows from that division. Dependencies come from the lock file; autoloading comes from generated code that must be regenerated when the mapping changes.
Keep namespaces and directories in exact correspondence under PSR-4, commit the lock file, express constraints with ^, and separate the deterministic install used on deploy from the deliberate update used in development.
Add composer validate --strict and composer audit to CI, and dependency drift and known vulnerabilities both become build failures rather than things discovered later.