Tags: web-dev concept

Composer and Autoloading

Date: 2026-09-27


Composer does two jobs that look like one: it installs dependencies, and it generates the autoloader that lets PHP find any class by name without a require. The second job is the one that shaped modern PHP — PSR-4 turned “namespace” into “folder path”, and every framework is built on that mapping.


Composer is PHP’s dependency manager: it resolves the versions in composer.json, installs them into vendor/, and records the exact result in composer.lock. Autoloading is PHP loading a class’s file the first time the class is used, via a callback, instead of the script requiring every file up front. Composer writes that callback for you.

The problem autoloading solves

// before autoloading: every file lists every file it needs, in order
require_once __DIR__ . '/../src/Orders/Order.php';
require_once __DIR__ . '/../src/Orders/LineItem.php';
require_once __DIR__ . '/../src/Money/Money.php';
// …forty more, and a missing one is a fatal error at runtime
 
// with Composer: one line, once, at the entry point
require __DIR__ . '/../vendor/autoload.php';

Because PHP rebuilds everything per request, loading only the classes a request actually touches is also a performance property, not just tidiness — The Request Lifecycle in PHP.

PSR-4: namespace to path

PSR-4 is the PHP-FIG standard (PHP Framework Interop Group, the body behind the “PHP Standards Recommendations”) mapping a namespace prefix to a base directory. The rest of the class name becomes the path.

{
  "autoload": {
    "psr-4": { "App\\": "src/" }
  },
  "autoload-dev": {
    "psr-4": { "App\\Tests\\": "tests/" }
  }
}
class name                      prefix   remainder         file
App\Orders\Order                App\     Orders\Order      src/Orders/Order.php
App\Money\Money                 App\     Money\Money       src/Money/Money.php
App\Tests\Orders\OrderTest      App\Tests\  Orders\OrderTest  tests/Orders/OrderTest.php

One class per file, the file named exactly as the class, the folders matching the namespace. Case matters on Linux and not on macOS, which is the classic “works on my machine, fatal in production” autoload bug.

The mechanism underneath — spl_autoload_register takes a function PHP calls whenever it meets an unknown class. A PSR-4 loader in its smallest form:

spl_autoload_register(function (string $class): void {
    $prefix = 'App\\';
    $baseDir = __DIR__ . '/src/';
 
    if (!str_starts_with($class, $prefix)) {
        return;                       // not ours — let the next registered loader try
    }
 
    $relative = substr($class, strlen($prefix));              // "Orders\Order"
    $file = $baseDir . str_replace('\\', '/', $relative) . '.php';   // "src/Orders/Order.php"
 
    if (is_file($file)) {
        require $file;               // PHP now knows the class; execution continues
    }
});
 
new App\Orders\Order();              // triggers the callback on first use

That’s all vendor/autoload.php is, plus a loader per installed package and some caching. Nothing about namespaces requires folders — the folder convention exists only because the autoloader reads it.

Other autoload types, met in older or mixed codebases:

  • classmap — scan directories and record every class’s file. Works with any layout; must be regenerated when classes are added
  • files — always included on every request. For helper functions, which autoloading can’t load because it only triggers on classes

Installing: the two commands that matter

composer install    read composer.lock, install exactly those versions
                    → CI, production, a colleague's fresh clone

composer update     re-resolve composer.json constraints, pick newest allowed,
                    REWRITE composer.lock
                    → deliberately, on a branch, reviewed like code

Commit composer.lock for applications. It’s the guarantee that production runs what was tested — Lockfiles. For a library, the lock is ignored by whoever installs it, so it matters only for the library’s own CI.

Version constraints use the npm-like vocabulary with one difference worth knowing:

^1.4      >=1.4.0  <2.0.0     the default and usually right
~1.4      >=1.4.0  <2.0.0     same as ^1.4 here
~1.4.2    >=1.4.2  <1.5.0     ← the tilde means "last digit given can move"
1.4.*     >=1.4.0  <1.5.0

The caret trusts Semantic Versioning — that minor releases won’t break you — which is a promise about the maintainer, not a fact about the code.

Production

composer install --no-dev --optimize-autoloader
  • --no-dev leaves out require-dev — test frameworks, debug toolbars — which have no business on a production server and some of which expose debug routes
  • --optimize-autoloader converts PSR-4 lookups into a classmap, trading a filesystem check per class for an array lookup. --classmap-authoritative goes further and never touches the filesystem for an unknown class — faster, but a class not in the map simply doesn’t exist
  • Build once, deploy the artefact. Running composer install on each production server at deploy time depends on the package registry being up at that moment — Continuous Deployment

Where it touches security

  • Packagist, the default registry, serves what maintainers publish. A compromised maintainer account is a compromised dependency — Supply Chain Risk
  • Composer plugins run code during install. Newer Composer versions require each plugin to be listed in allow-plugins before it can run [CHECK: introduced in Composer 2.2 — verify]. Scripts in dependencies’ composer.json don’t run; the root project’s own scripts do
  • composer audit checks installed versions against known advisories [CHECK: availability by Composer version]. Run it in CI — Dependency Management
  • vendor/ must never be web-accessible. The document root is public/, not the project root — a misconfigured server that serves vendor/ exposes files never meant to be requested directly

vs JS: composer.json / composer.lock / vendor/ map onto package.json / the lockfile / node_modules/. The difference is that Composer installs one version of each package for the whole project — no nested copies — so a version conflict fails the resolve instead of silently shipping two copies — Package Managers.