Skip to content
intermediate Phase 8 · Composer and Autoloading

PSR-4 Autoloading

Map namespaces to directories so classes load on demand.

45m
0 problems
Topic Progress 0%

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.php can 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\Parser matches both Acme\Orders\ and Acme\Orders\Legacy\; the longer prefix drops legacy-src/ in and the leftover Parser lands directly in it — which is why the third row is legacy-src/Parser.php, not legacy-src/Legacy/Parser.php.
  • Prefixes end with a backslash. "Acme\\Orders\\", not "Acme\\Orders". Without the trailing separator, Acme\OrdersX\Thing matches 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 {} under Order.php loads fine on macOS and Windows and fatals on Linux production with Class ... 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?

Question 1 options

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?

Question 2 options

3. What is the difference between the psr-4, classmap, and files sections of the autoload block?

Question 3 options

4. What does composer dump-autoload -a (classmap-authoritative) change?

Question 4 options

Flashcards

Question

What is the exact lookup sequence when `new Acme\Orders\Domain\Order()` runs for the first time?

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?

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?

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

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

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.

Continue Learning