From Fatal Error to File Lookup: What Happens When a Class Is Used
Early PHP projects began with a wall of includes: require_once 'lib/Order.php'; require_once 'lib/OrderLine.php'; — dozens of lines that had to be kept in exact dependency order by hand, in every entry point. Forget one and you meet the error every PHP developer remembers:
PHP Fatal error: Uncaught Error: Class "Acme\Orders\Domain\Order" not found in /app/src/Service/Checkout.php:14
Autoloading replaces that wall with a contract: when PHP encounters a class name it has not loaded, it calls a registered function and lets that function find the file. Composer registers its loader through spl_autoload_register() — the standard extension point since PHP 5.1 — so multiple loaders (yours, a framework's, a legacy shim's) queue instead of colliding.
The whole mechanism hangs on one line at the top of every entry point:
<?php
require __DIR__ . '/vendor/autoload.php'; // register every autoloader
$order = new Acme\Orders\Domain\Order(); // first use triggers the lookup
Read the sequence precisely, because interviews ask for it: the new statement names a class PHP has never loaded; PHP invokes the autoload queue; Composer's loader converts Acme\Orders\Domain\Order into a candidate path under each configured base directory (src/Domain/Order.php); on the first hit it includes the file and retries the new. A genuine miss — no mapping covers the namespace, or the file is absent — surfaces as the Error above, now thrown at the point of use rather than hidden in an include list.
The same hook covers every class-shaped construct: instantiating, extending, implementing, using a trait, or merely type-hinting a class all trigger the lookup, so an inheritance chain loads one file at a time as it is touched. Functions and constants are the exception — nothing autoloads them — which is exactly why Composer keeps a separate files mechanism, shown in the next chapter.
Three properties follow from "on demand":
- Nothing is loaded that is not used. A CLI command that never touches the web namespace never pays for those files — autoloading is lazy by design, which is why
vendor/autoload.phpcan sit in front of two thousand classes without slowing the request. - The require must precede every use, in every entry point. Front controllers, console scripts, cron files, and test runners each need it once, at the top. A PHPUnit bootstrap, a queue worker, and a migration script are three separate entry points — each one starts with the same line.
- Cost is paid on first use, not install. Each miss before the hit costs a few
file_exists()probes along the namespace path; the optimized build collapses this (chapter 3).
A historical note interviews still probe: PSR-4 replaced PSR-0, where the entire namespace — underscores included — became directory nesting (Acme_Orders_Domain_Order living in Acme/Orders/Domain/Order.php, with vendor trees mirrored the same way). PSR-4 declares the prefix once and maps only the remainder, so the path structure reflects the domain, not the class name's spelling. Code that still hand-mirrors full namespaces on disk is carrying the PSR-0 idea without its autoloader.
The loader also registers "files" — plain PHP files loaded unconditionally on every request — so vendor/autoload.php is a small program, not just a class map. Everything it knows comes from Composer's manifests, which is the subject of the next chapter; the only thing this line does is turn "where is that class?" from a chore into a lookup.
The autoload Block: Mapping Namespace Prefixes to Directories
PSR-4 is the standard that makes the lookup predictable: a namespace prefix maps to a base directory, and the rest of the class name maps to subdirectories, character for character. One rule, applied mechanically:
{
"autoload": {
"psr-4": {
"Acme\\Orders\\": "src/",
"Acme\\Orders\\Legacy\\": "legacy-src/"
},
"files": ["src/helpers.php"]
},
"autoload-dev": {
"psr-4": {
"Acme\\Orders\\Tests\\": "tests/"
}
}
}
Given that manifest, the mapping is arithmetic:
| Fully qualified class | File |
|---|---|
Acme\Orders\Domain\Order |
src/Domain/Order.php |
Acme\Orders\Http\CartController |
src/Http/CartController.php |
Acme\Orders\Legacy\Parser |
legacy-src/Parser.php |
Acme\Orders\Tests\Unit\OrderTest |
tests/Unit/OrderTest.php |
The mechanics behind the table:
- Longest prefix wins.
Acme\Orders\Legacy\Parsermatches bothAcme\Orders\andAcme\Orders\Legacy\; the longer prefix dropslegacy-src/in and the leftoverParserlands directly in it — which is why the third row islegacy-src/Parser.php, notlegacy-src/Legacy/Parser.php. - Prefixes end with a backslash.
"Acme\\Orders\\", not"Acme\\Orders". Without the trailing separator,Acme\OrdersX\Thingmatches the prefix textually and resolves into the wrong tree. Composer normalizes this for you, but hand-edited manifests get it wrong. - Names are case-sensitive, filesystems often are not.
class order {}underOrder.phploads fine on macOS and Windows and fatals on Linux production withClass ... not found. This single mismatch explains a whole genre of "works locally, dies on deploy" incidents.
Directories resolve relative to composer.json — the manifest at the project root, which is also why Composer commands run in a subdirectory still act on the same project (the upward search introduced in Initializing a Project with Composer).
A prefix may map to several directories — the value accepts an array — letting one namespace span src/ plus a generated tree: "Acme\\Orders\\": ["src/", "generated/api/"], checked in order. The mirror-image tool is exclude-from-classmap, which keeps rebuild-hot generated files out of -o scans so production dumps do not churn on directories that change every build. Both options are the same design statement: Composer owns a map, and every knob says what belongs in it.
After any manifest change you regenerate: composer dump-autoload. The command rebuilds the loader maps from every package's manifest plus yours — it does not install or remove anything.
The files section is the escape hatch for code that is not a class: helper function libraries (array_get(), framework bootstrap snippets) that must exist on every request. It is included eagerly — there is no lazy loading for functions — so each entry costs every request, and a large files list is a measurable drag. The classmap section is the other escape hatch: Composer scans the listed directories at dump time and records exact class-to-file paths, which rescues non-PSR-4 layouts (mixed legacy code, generated classes) without renaming a thing.
autoload-dev follows the same PSR-4 syntax but is loaded only when dev dependencies are installed. Test namespaces stay off production servers; the trade-off is that a dev-only class referenced from production code fails only in production — a failure mode chapter 3 turns into a checklist item. This is also where Installing and Updating Packages's --no-dev flag has teeth: composer install --no-dev produces an autoloader with no autoload-dev mappings at all.
Class Exists on Disk, Still Not Found: The Four Real Causes
The Class "..." not found error names a class that is visibly there in the editor. Before assuming a dependency problem, walk the four causes in the order they occur in practice.
1. A stale autoload map. You pulled a branch that added a class; Composer's maps in vendor/composer/ do not know it exists. The tell is that the class loads in one checkout and not in another. composer dump-autoload regenerates in about a second and fixes it — the same fix as deleting a cached build, and just as mechanical. Some teams wire it into their post-pull ritual (git pull && composer install) precisely because the stale-map report looks so much like a missing package.
2. Case mismatch. use Acme\orders\Domain\Order; where the namespace is Acme\Orders works on a case-insensitive developer filesystem (macOS, Windows) and fatals on Linux. The defensive habit is case-correct use statements everywhere and, for teams that can afford it, a CI step that boots the app on Linux with the production autoloader.
3. Wrong or missing mapping. The namespace you used is not covered by any prefix — often after a namespace rename, when old files still declare the previous name, or when the new subdomain (Acme\Billing\) was never added to autoload.psr-4. Symptom: only that family of classes fails, and composer dump-autoload -o does not help because the class was never in the map.
4. Dev-only class in production code. A class defined in autoload-dev works on every developer machine (all installs there are dev installs) and fatals behind composer install --no-dev, where the dev mappings are absent by design. The one-line audit is grepping prod code for test namespaces; the deeper answer is that anything reachable from a deployable path belongs in autoload, not autoload-dev.
Once the cause is clear, performance tuning follows the same maps. composer dump-autoload -o scans every PSR-4 directory at dump time and folds the results into a single flat classmap, trading a slower dump and a bigger generated file for near-zero lookup cost per request — the default choice for production (composer install --no-dev --optimize-autoloader covers it). -a goes further: classmap-authoritative mode makes the loader refuse every class not in the map, killing the fallback directory scans. It is the strictest and fastest mode, and the one that turns cause 3 above into an instant, loud failure instead of a slow probe chain.
One-line verification belongs in CI next to composer install, because it turns causes 3 and 4 from production surprises into build failures:
php -r "require 'vendor/autoload.php'; var_dump(class_exists('Acme\\Orders\\Domain\\Order'));"
# bool(true)
class_exists() runs the real autoloader, so a false here means the map genuinely does not cover the class — on the exact machine and the exact flags the deploy will use.
"How does autoloading work in a Composer project?" is the most common architecture question in PHP interviews. The answer that lands: vendor/autoload.php registers an spl_autoload_register callback; on first use of a class, PSR-4 translates the namespace prefix to a base directory and includes the matching file; dump-autoload -o precomputes that translation into a flat classmap for production; and when it breaks, the four suspects are stale maps, case, mapping coverage, and --no-dev.
Quiz
1. With the mapping "Acme\\Orders\\": "src/", from which file does PHP load the class Acme\Orders\Legacy\Parser?
2. A class loads on a developer's macOS laptop but fatals with Class not found in the Linux production container. What is the most likely cause?
3. What is the difference between the psr-4, classmap, and files sections of the autoload block?
4. What does composer dump-autoload -a (classmap-authoritative) change?
Flashcards
Question
What is the exact lookup sequence when `new Acme\Orders\Domain\Order()` runs for the first time?
Click to reveal answer
Answer
The class is not loaded, so PHP invokes the spl_autoload_register queue; Composer's loader matches the longest PSR-4 prefix (Acme\Orders\ → src/), appends the remainder as subdirectories (Domain/Order.php), includes the file, and the class definition is retried. No mapping match surfaces as an Error: Class not found at the point of use.
Question
What does a PSR-4 mapping look like in composer.json, and what must the prefix end with?
Click to reveal answer
Answer
"autoload": { "psr-4": { "Acme\\Orders\\": "src/" } } — namespace prefix to base directory, prefix ending in a backslash. Class Acme\Orders\Domain\Order loads from src/Domain/Order.php; the longest matching prefix wins when mappings nest.
Question
autoload-dev vs autoload: what changes behind composer install --no-dev?
Click to reveal answer
Answer
autoload-dev mappings (typically test namespaces) are omitted from the generated autoloader when dev dependencies are not installed. Dev machines never see the difference; a dev-only class referenced from production code fatals only in production — so anything reachable from deployable paths belongs in autoload.
Question
The four real causes of 'Class not found' when the file exists on disk
Click to reveal answer
Answer
1) stale autoload map after a pull — dump-autoload; 2) case mismatch in namespace/class/use, invisible on macOS/Windows, fatal on Linux; 3) namespace not covered by any PSR-4 prefix (e.g. after a rename); 4) class registered in autoload-dev but used in prod code behind --no-dev.
Question
composer dump-autoload vs -o vs -a — and when files/sections cost you
Click to reveal answer
Answer
dump-autoload rebuilds the maps; -o pre-scans PSR-4 dirs into a flat classmap for fast production lookups (use with --no-dev on deploy); -a is classmap-authoritative: unmapped classes fail instantly, and every new class requires a re-dump. The files section loads helper files on every request — lazy loading does not apply, so keep that list short.
Revision Notes
Key Takeaways
- 1. Autoloading replaces hand-ordered require lists with a callback: missing class → spl_autoload_register queue → PSR-4 path translation → include → retry
- 2. One PSR-4 mapping turns the fully qualified class name into a path by pure string arithmetic — prefix stripped, remainder becomes subdirectories, longest prefix wins
- 3. Namespaces and class names are case-sensitive; macOS/Windows filesystems hide mismatches that Linux production turns into Class not found fatals
- 4. psr-4 is lazy, classmap is a dump-time scan, files are eagerly included on every request — each trades eagerness for coverage
- 5. dump-autoload -o/-a precompute the lookup for production; -a refuses unmapped classes outright, making mapping mistakes loud instead of slow
Interview Tips
- • "How does PSR-4 autoloading work?" — the full sequence (require autoload.php, spl callback, longest-prefix match, path translation, include, retry) plus one concrete mapping example; naming spl_autoload_register shows you know the mechanism is a queue, not a Composer invention
- • "Works on my machine, Class not found in prod" — walk the four suspects: stale map, case sensitivity, mapping coverage, autoload-dev behind --no-dev; this question is a scripted opportunity, not a trap
- • "-o versus -a" — flat classmap for speed vs authoritative mode for strictness; state the cost: -a requires a re-dump whenever classes are added
- • "Why not just require the files directly?" — include lists rot (order, duplication, missing entries in new entry points); autoloading is lazy, self-healing on additions, and uniform across every entry point
Cheat Sheet
PSR-4 Autoloading
Mechanism: require vendor/autoload.php in every entry point (front controller, CLI, tests, cron) → spl_autoload_register queue → first use of a class → longest PSR-4 prefix matched → remainder becomes the path → file included → new retried.
Mapping:
"autoload": {
"psr-4": { "Acme\\Orders\\": "src/" },
"files": ["src/helpers.php"]
},
"autoload-dev": { "psr-4": { "Acme\\Orders\\Tests\\": "tests/" } }
Acme\Orders\Domain\Order → src/Domain/Order.php. Prefix ends with \; longest prefix wins; names are case-sensitive even when the filesystem is not.
Sections: psr-4 = lazy lookup · classmap = dump-time scan (rescues odd layouts) · files = eager include every request (functions/bootstraps).
Commands: composer dump-autoload (rebuild) · -o (flat classmap, production) · -a (authoritative: no fallback scanning).
Not-found triage: stale map → dump-autoload · case mismatch → check on Linux · unmapped namespace → fix psr-4 block · works-dev-only-prod → autoload-dev in prod code.