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.

JSON
The autoload section of composer.json.
{
    "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 nameFile path
Acme\Shop\Ordersrc/Order.php
Acme\Shop\Billing\Invoicesrc/Billing/Invoice.php
Acme\Shop\Http\Controller\Cartsrc/Http/Controller/Cart.php
Acme\Shop\Tests\OrderTesttests/OrderTest.php
PHP
A class in src/Billing/Invoice.php — the namespace must match the path.
<?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
One require line replaces every manual include.
<?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);
Note: The namespace prefix is case-sensitive and the trailing \\ 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.jsoncomposer.lock
ContentsVersion constraintsExact resolved versions
Edited byYouComposer only
Commit to gitYesYes — always
Changed byrequire, removeupdate, require, remove
Note: Commit composer.lock for applications without exception — it is what makes a deployment reproducible. Libraries intentionally omit it, because the consuming application resolves the versions.
Bash
The distinction between install and update.
# 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.

ConstraintAllowsBlocks
^3.53.5 up to < 4.04.0 (breaking)
~3.53.5 up to < 3.63.6 (new features)
~3.5.23.5.2 up to < 3.6.03.6.0
3.5.*Any 3.5.x3.6.0
3.5.2Exactly 3.5.2Everything else
>=3.5 <4.0Explicit rangeOutside 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.

Note: For versions below 1.0, semantic versioning treats the minor as the breaking position. ^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.

JSON
Scripts and platform configuration in composer.json.
{
    "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.

Bash
The production install command, and what each flag does.
# 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.

Note: --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.
• PSR-4 maps a namespace prefix to a directory; paths must match namespaces exactly, including case.
• 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.