Autoloading: From require Chains to PSR-4 and Composer
Understand how PHP loads class definitions on demand — from manual require/require_once, through the legacy PSR-0 convention, to the modern PSR-4 standard implemented by Composer's autoloader — including how a composer.json autoload block maps namespaces to directories, how classmap and optimized autoloading work, and why each matters for production performance.
Every class, interface, trait, and enum a PHP script uses has to be defined somewhere before it's referenced — PHP doesn't compile a whole project up front the way some languages do, it parses and executes top to bottom, so a class must be either already defined earlier in the same request or loaded on demand at the moment it's first needed. Autoloading is the mechanism that makes 'load it on demand' practical: instead of manually writing dozens of require statements listing every file a script might touch, you register a function that PHP calls automatically, exactly once, the first time an unknown class name is referenced, and that function's only job is to figure out which file defines it and include it.
The manual era: require and require_once
require 'path/to/File.php'; reads and executes a PHP file inline at that point in the script, and fails with a fatal error if the file doesn't exist (its sibling, include, only emits a warning and continues — which is almost never what you want for code your script depends on to function, so require is the correct default for loading definitions). require_once is identical but tracks which files it has already loaded and silently skips a file it has seen before, which matters enormously once files start requiring each other: without _once, a diamond-shaped dependency graph (A requires B and C, both B and C require D) would load D twice, causing a fatal 'cannot redeclare class' error.
Before autoloading existed, every PHP file had to manually require_once every class it directly used, and transitively, every file those files needed too — in practice this meant either a single giant bootstrap file with a long, carefully hand-maintained list of require_once statements in dependency order, or files scattered with require_once calls for their immediate dependencies. Both approaches scale badly: adding one new class means finding every place that might need to load it, and reordering or renaming files means hunting down and fixing every path string that references them.
Manual require/require_once still has legitimate, narrow uses today — loading a one-off configuration file, a small bootstrap script, or vendor/autoload.php itself (which is, itself, just a generated file loaded with require) — but for class definitions specifically, autoloading has been the standard approach in essentially all modern PHP code since PSR-4 and Composer became ubiquitous around 2013-2014.
spl_autoload_register and the autoloading contract
PHP's autoloading hook is spl_autoload_register(callable $callback), which registers a function PHP will call automatically — passing the fully qualified class name as its only argument — the moment code references a class that isn't defined yet. The callback's job is simple: given a class name string, figure out the file that should define it, and require it if it exists; if the class still isn't defined afterward, PHP moves on to the next registered autoloader (multiple can be stacked) and, if none succeed, throws a fatal 'Class not found' error.
This single function is the entire foundation autoloading standards are built on top of — PSR-4 and the legacy PSR-0 are simply naming conventions describing HOW a class name should map to a file path, so that a generic, reusable autoloader callback (rather than one hand-written per project) can be written once and work for any correctly-organized codebase.
PSR-4: the modern standard
PSR-4 defines a simple, precise mapping: a namespace prefix corresponds to a base directory, and each namespace segment after that prefix corresponds to a nested subdirectory, with the final segment (the class's own short name) corresponding to a file of that exact name plus .php. So if App\ is mapped to the src/ directory, the class App\Domain\Orders\Order must live at exactly src/Domain/Orders/Order.php — the mapping is entirely mechanical and requires no filesystem scanning, no convention-guessing, just direct string substitution from namespace to path.
Crucially, PSR-4 allows exactly one class (or interface/trait/enum) per file, and requires the class name to match the filename exactly, including case — this strictness is precisely what makes the mapping mechanical and fast: given any class name, the autoloader computes the expected file path directly and either it exists or it doesn't, with zero directory scanning needed.
Composer, the standard PHP dependency manager, generates a fully PSR-4-compliant autoloader automatically from the autoload block of a project's composer.json, and every published Composer package is expected to declare its own PSR-4 mapping the same way — which is exactly what makes composer install 'just work' for wiring up autoloading across an entire dependency tree without any project ever needing to hand-write a single require_once for a vendor class.
PSR-0: the legacy convention (awareness, not use)
PSR-0, PSR-4's predecessor (formally deprecated in 2014 but still occasionally encountered in older packages), used a stricter, more literal mapping: underscores in a class name outside its namespace were also treated as directory separators (Zend_Http_Client mapped to Zend/Http/Client.php even with no actual namespace involved — a holdover convention from before PHP had real namespaces at all), and every namespace segment had to correspond to an actual directory, with no ability to map a namespace prefix to an arbitrary base directory the way PSR-4 allows.
The main practical differences from PSR-4: PSR-0 required the full namespace-to-directory correspondence with no shortcuts, and it retained underscore-as-separator behavior for backward compatibility with pre-namespace code, which PSR-4 dropped entirely (in PSR-4, an underscore in a class name is just a literal character with no special filesystem meaning). Modern PHP code should never be written to target PSR-0 — it exists in this lesson purely so you can recognize it if you encounter it in an old, unmaintained library and understand why its directory layout looks stricter and more rigid than a modern PSR-4 package.
Composer classmaps and optimized autoloading
Not every class fits PSR-4's clean mapping — legacy libraries with irregular file layouts, or files with multiple class definitions — so Composer also supports a classmap autoload strategy: instead of a namespace-to-directory rule, you point it at specific directories or files, and Composer scans them once (at composer install/composer dump-autoload time) to build an explicit, static lookup table of every class name found mapped directly to its exact file path. This costs a one-time scan but produces the fastest possible lookup afterward — a plain array key lookup, no path computation, no filesystem stat calls at runtime.
By default, Composer's PSR-4 autoloader still does a small amount of runtime work per class load: computing the expected path from the namespace and checking the filesystem. composer dump-autoload --optimize (or composer install --optimize-autoloader / -o) converts the entire PSR-4 mapping into a single flat classmap ahead of time, the same fast static array Composer already uses natively for classmap-strategy packages — trading a slightly longer build step for meaningfully faster runtime class resolution, since production requests then do one array lookup instead of any path-computation logic.
The standard production practice is: run composer install --no-dev --optimize-autoloader (sometimes combined with --classmap-authoritative, which skips the filesystem fallback check entirely and trusts the classmap completely, going even faster but requiring a fresh dump-autoload after every deploy that adds classes) as part of the deployment pipeline, while local development typically skips --optimize so that newly added files are picked up immediately without needing to regenerate the autoloader on every single file addition.
• PSR-4: mechanical namespace-to-directory mapping, one class per file, fast because no scanning is needed to know where a class should be. • classmap: Composer scans given directories/files once and builds a static class-name-to-path table, useful for legacy code that doesn't follow PSR-4. • Optimized autoloading (--optimize-autoloader): flattens the whole PSR-4 mapping into a classmap ahead of time, for production speed. • --classmap-authoritative: skips the filesystem fallback entirely, trusting the generated classmap completely — fastest, but requires re-running dump-autoload after adding classes.
composer.json autoload block and its file-path mapping
The trailing backslash in "App\\": "src/" matters conceptually (it's the namespace prefix being mapped) even though composer.json's JSON escaping doubles it to \\. The files autoload key unconditionally requires listed files on every request — used for legacy global function libraries that can't be class-autoloaded, not for regular classes. vendor/autoload.php is Composer-generated and already wires up PSR-4, classmap, and files entries from your own composer.json plus every installed package's own composer.json.
{
"autoload": {
"psr-4": {
"App\\": "src/",
"App\\Tests\\": "tests/"
},
"classmap": [
"legacy/OldLibrary.php",
"legacy/vendor-no-namespace/"
],
"files": [
"src/bootstrap-functions.php"
]
}
}
// What this PSR-4 mapping implies about file locations:
// App\Domain\Orders\Order -> src/Domain/Orders/Order.php
// App\Http\Controllers\OrderController -> src/Http/Controllers/OrderController.php
// App\Tests\Domain\OrderTest -> tests/Domain/OrderTest.php
// Registering the manual, pre-Composer equivalent (for comparison only):
spl_autoload_register(function (string $class): void {
$prefix = 'App\\';
$baseDir = __DIR__ . '/src/';
if (!str_starts_with($class, $prefix)) {
return; // not ours; let another registered autoloader try
}
$relative = substr($class, strlen($prefix));
$file = $baseDir . str_replace('\\', '/', $relative) . '.php';
if (is_file($file)) {
require $file;
}
});
// The one line every Composer-managed project actually uses in practice:
require __DIR__ . '/vendor/autoload.php';Code Examples
The manual version requires every dependency to be listed explicitly and in an order that satisfies every file's own internal requirements; forgetting one, or listing them in the wrong order relative to inheritance, causes a fatal error. The autoloaded version needs only the single generated vendor/autoload.php, and every class loads itself, exactly once, only when actually used.
The PSR-4 mapping App\ -> src/ means Composer's autoloader can compute the exact file for any App\... class without scanning anything: strip the App\ prefix, replace remaining backslashes with slashes, append .php, look under src/. This is why file layout under src/ must mirror the namespace hierarchy exactly, including case.
"psr-4": { "App\\": "src/" } classmap handles the class that structurally cannot fit PSR-4 (no namespace, irregular naming) by scanning it once at build time instead of computing its path from a naming rule. --optimize-autoloader then unifies everything -- PSR-4-derived and classmap-derived -- into one flat, fast lookup table for production, trading a build-time scan for eliminating runtime path computation entirely.
composer install --no-dev --optimize-autoloader
<?php
// Before autoloading: manual, brittle, order-sensitive
require_once __DIR__ . '/src/Domain/Orders/Order.php';
require_once __DIR__ . '/src/Domain/Orders/OrderId.php';
require_once __DIR__ . '/src/Domain/Orders/OrderRepository.php';
require_once __DIR__ . '/src/Infrastructure/Persistence/DoctrineOrderRepository.php';
$repo = new \App\Infrastructure\Persistence\DoctrineOrderRepository();
// After Composer autoloading: one line, order doesn't matter
require __DIR__ . '/vendor/autoload.php';
$repo = new \App\Infrastructure\Persistence\DoctrineOrderRepository();
// Order.php, OrderId.php, and OrderRepository.php are all loaded automatically,
// on demand, the moment each class is actually referenced -- no manual list needed.// composer.json
{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
// File that MUST exist at exactly this path for PSR-4 to find it:
// src/Domain/Orders/OrderId.php
<?php
namespace App\Domain\Orders;
final class OrderId
{
private function __construct(public readonly string $value) {}
public static function generate(): self
{
return new self(bin2hex(random_bytes(8)));
}
}
// Anywhere else in the project, after `composer dump-autoload`:
<?php
require __DIR__ . '/vendor/autoload.php';
use App\Domain\Orders\OrderId;
$id = OrderId::generate();
echo $id->value;
// No require/require_once for OrderId.php was ever written --
// Composer's PSR-4 autoloader computed src/Domain/Orders/OrderId.php
// directly from the class name App\Domain\Orders\OrderId the first
// time OrderId::generate() was called.// composer.json for a project mixing modern PSR-4 code with one legacy file
{
"autoload": {
"psr-4": {
"App\\": "src/"
},
"classmap": [
"legacy/LegacyPriceCalculator.php"
]
}
}
// legacy/LegacyPriceCalculator.php -- no namespace, doesn't fit PSR-4 at all
<?php
class LegacyPriceCalculator
{
public static function calculate(float $base): float
{
return round($base * 1.2, 2);
}
}
// After `composer dump-autoload`, Composer scans legacy/LegacyPriceCalculator.php,
// finds the class LegacyPriceCalculator, and adds it to the generated
// vendor/composer/autoload_classmap.php as a direct path entry --
// no namespace-based computation needed for this one class.
// Deployment step for production:
// composer install --no-dev --optimize-autoloader
// This flattens the ENTIRE psr-4 mapping (every App\... class) into
// that same fast classmap array too, so in production every class --
// PSR-4 or classmap in origin -- resolves via one array lookup,
// with zero runtime path computation or filesystem probing.From class reference to loaded file
Whether via live PSR-4 path computation or a pre-built optimized classmap, the end result is the same file getting required exactly once, on first use.
new App\Domain\Orders\OrderId()
|
v
class not yet defined -> PHP calls registered autoloader(s)
|
v
Composer's ClassLoader::loadClass('App\\Domain\\Orders\\OrderId')
|
optimized? ------------------- yes --> array lookup in
| classmap.php -> file path
no
|
v
find longest matching PSR-4 prefix ("App\\" -> "src/")
|
v
strip prefix, replace \\ with /, append .php
|
v
src/Domain/Orders/OrderId.php -- require it
|
v
class App\Domain\Orders\OrderId now defined -> execution continuesImportant Rules
require fails fatally if the file is missing; include only warns — always use require (or require_once) for code your script depends on to run.
require_once guards against loading the same file twice, which matters once files require each other in a diamond-shaped dependency graph.
spl_autoload_register() is the underlying hook every autoloading standard (PSR-4, PSR-0, Composer's own) is built on top of.
PSR-4 maps a namespace prefix to a base directory mechanically: strip the prefix, turn remaining backslashes into slashes, append .php — one class per file, exact case match required.
PSR-0 is legacy: it additionally treats underscores in a class name as directory separators and requires the full namespace path to correspond to real directories with no arbitrary base-directory shortcut.
Use Composer's classmap strategy for legacy code that doesn't fit PSR-4; use --optimize-autoloader (or --classmap-authoritative) in production for the fastest possible class resolution.
Where this shows up
Essentially every modern PHP project — Symfony, Laravel, WordPress plugins built with modern tooling, and virtually all published Composer packages — relies on require __DIR__ . '/vendor/autoload.php'; as the single autoloading entry point, with PSR-4 mappings declared in each package's own composer.json so that installing a dependency via composer require makes its classes immediately available with zero manual wiring. Production deployment pipelines for high-traffic PHP applications routinely run composer install --no-dev --optimize-autoloader --classmap-authoritative specifically because, at scale, the difference between computing a file path per class load and doing a flat array lookup measurably affects response time across millions of requests.
// composer.json { "autoload": { "psr-4": { "App\\": "src/" } } } // File actually placed at: src/domain/orders/order.php (wrong case, wrong nesting) namespace App\Domain\Orders; class Order {}
// File correctly placed at: src/Domain/Orders/Order.php namespace App\Domain\Orders; class Order {}
PSR-4's mapping from namespace to file path is a mechanical, case-sensitive string transformation, not a fuzzy search. App\Domain\Orders\Order MUST resolve to src/Domain/Orders/Order.php exactly; a lowercase or differently-nested path will fail to autoload even though the file 'obviously' contains the right class to a human reader, especially on case-sensitive filesystems (Linux production servers).
// Added a new class file, then wondered why it 'can't be found' in production // after only running git pull on the server -- forgot the autoloader step
// After adding/moving classes, regenerate the autoloader: // composer dump-autoload --optimize // (or re-run composer install if dependencies also changed)
An optimized/classmap-based autoloader is a snapshot generated at a point in time — adding a new PSR-4 class file doesn't retroactively update an already-optimized classmap. Any deploy that adds or moves classes must re-run composer dump-autoload (or composer install) so the generated autoload files reflect the current file layout.
[multiple-choice] What is the key practical difference between require and require_once? A. require_once is faster in every case regardless of how many times a file is loaded B. require_once tracks which files have already been loaded and skips re-loading them, preventing 'cannot redeclare class' errors in diamond-shaped dependency graphs C. require only works for classes, require_once only works for plain scripts D. There is no difference; they are aliases of the same behavior
B. require_once tracks which files have already been loaded and skips re-loading them, preventing 'cannot redeclare class' errors in diamond-shaped dependency graphs — require_once is required whenever files might require each other (directly or transitively) more than once along different paths, since loading the same class-defining file twice causes a fatal redeclaration error. Plain require has no such tracking.
[multiple-choice] Given "psr-4": { "App\\": "src/" }, where must the class App\Domain\Orders\Order be located? A. src/App/Domain/Orders/Order.php B. src/Domain/Orders/Order.php C. src/Order.php D. Anywhere, as long as it's somewhere under src/
B. src/Domain/Orders/Order.php — PSR-4 strips the mapped prefix (App\) from the class name before building the path, so only the remaining segments (Domain\Orders\Order) become directories/filename under the mapped base directory (src/), giving src/Domain/Orders/Order.php.
[predict-output] What does composer install --no-dev --optimize-autoloader do that a plain composer install does not? A. It installs a completely different set of packages B. It flattens the whole PSR-4 namespace-to-directory mapping into a single static classmap ahead of time, so production class loading becomes a fast array lookup instead of computing paths at runtime C. It disables autoloading entirely for faster boot time D. It converts all classes to use PSR-0 instead of PSR-4
B. It flattens the whole PSR-4 namespace-to-directory mapping into a single static classmap ahead of time, so production class loading becomes a fast array lookup instead of computing paths at runtime — --optimize-autoloader (or --classmap-authoritative for an even stricter version) pre-computes every PSR-4 class's file path once at build time into a flat array, trading a slightly longer install step for eliminating per-class path computation and filesystem probing on every production request. --no-dev separately skips installing require-dev packages.
Given a composer.json with "psr-4": { "Acme\\Blog\\": "lib/" }, write the exact file path (relative to the project root) that the class Acme\Blog\Http\Controllers\PostController must live at for PSR-4 autoloading to find it. Then explain, in one or two sentences, what composer dump-autoload --optimize changes about how that class gets located at runtime once you've verified the file path.
File path: lib/Http/Controllers/PostController.php --optimize flattens the namespace-prefix-to-directory rule into a pre-computed, static array mapping the full class name Acme\Blog\Http\Controllers\PostController directly to that file path, so at runtime Composer's autoloader does one array lookup instead of stripping the prefix and building the path string on every class load.
Strip the mapped prefix (Acme\Blog\) from the class name, convert remaining backslashes to forward slashes, append .php, and prepend the mapped base directory (lib/).