Lesson 09 / 12
Autoloading
An autoloader built with spl_autoload_register; mapping a namespace prefix to a directory and PSR-4; the number of files not deciding the reach when a class file moves; a move made without updating the loader stopping the program; the mapping depending on letter case in the file name.
Published Updated
In this lesson
- Writes an autoloader with spl_autoload_register that maps a namespace prefix to a directory
- Measures that when a class file moves, the loader absorbs the change as a single seam
- Shows that whether the consumer is edited, not the number of files, decides the reach
Contents
In the Namespaces lesson every class was pulled in by hand with require. That means the consumer knows in which file a class lives; if the class file moves, that knowledge goes stale on every require line. In this lesson the same knowledge is gathered in one place: the autoloader. The question is again the one asked throughout the course: where does this change reach?
M09/K02 looked for the place where a name is bound. The loader is the place where the binding between a name and a file path is made. This lesson measures how many files are edited when that binding changes, and what the unedited code experiences.
Manual Loading and Its Cost
Before loaders, every class file was pulled in by hand with require_once. This method has two costs. First, the consumer file must know the path of every class it uses; if a path changes, every require_once line goes stale. Second, the lines run whether or not the class is used: a program that needs only one class loads all four. Because classes themselves need other classes, these lines turn into a chain, and the order in which the chain is built also becomes the consumer’s burden.
The second half of the cost can be counted. The driver below writes four empty class files and builds two separate programs: one pulls in the four files by hand, the other registers a loader. Both use only the class A and print at the end how many files were loaded.
<?php // chain.php — manual loading loads every file, the loader loads only what is used mkdir('measure/chain/src', 0777, true); foreach (['A', 'B', 'C', 'D'] as $name) { file_put_contents("measure/chain/src/$name.php", "<?php\nclass $name {}\n"); } $manual = <<<'PHP' <?php require_once 'src/A.php'; require_once 'src/B.php'; require_once 'src/C.php'; require_once 'src/D.php'; new A(); echo count(get_included_files()) - 1, "\n"; PHP; $automatic = <<<'PHP' <?php spl_autoload_register(fn (string $class) => require __DIR__ . "/src/$class.php"); new A(); echo count(get_included_files()) - 1, "\n"; PHP; file_put_contents('measure/chain/manual.php', $manual); file_put_contents('measure/chain/auto.php', $automatic); foreach (['manual', 'auto'] as $name) { echo str_pad($name, 7), exec("php measure/chain/$name.php"), " loaded\n"; }
manual 4 loaded auto 1 loaded
The manually written version loaded four files although the program asked only for A. The version with a loader loaded one file: B, C and D were never requested and never opened. The difference grows with the number of classes; in an application with three hundred classes, each request reads only the files of the classes it touches.
When the Loader Is Called
spl_autoload_register registers a function as a loader. When PHP meets a class name it does not know, it calls this function with the fully qualified name of the class; the function is responsible for loading the file. If the class is already defined, or if the call skips the loader, the function does not run.
<?php // observe.php — the loader is called only for an unknown class spl_autoload_register(function (string $class): void { echo "requested: $class\n"; }); var_dump(class_exists('Shop\Model\Product')); var_dump(class_exists('Shop\Model\Product', false)); echo "done\n";
requested: Shop\Model\Product bool(false) bool(false) done
The first class_exists called the loader with the fully qualified name. In this example the loader loaded no file, so the class still could not be found and the result was false. The second call skipped the loader with its second argument: the function did not run at all. A loader is not a declaration but a callback; PHP calls it only for a missing class and only at that moment.
Which Operations Call the Loader
The loader is called wherever PHP has to resolve a class name and does not yet know the class. Roughly, these are the operations that really need the class to exist: new, a static call, a class constant, class_exists. Operations that do not require the class to exist do not call the loader: instanceof, ::class and a parameter type declaration. The driver below registers the loader as a function that loads no class, so after each operation it becomes visible whether the loader was called. A class that cannot be found throws an Error; the driver catches the error and carries on.
<?php // trigger.php — which operations call the loader spl_autoload_register(function (string $class): void { echo " loader: $class\n"; }); function probe(string $name, callable $action): void { echo "$name\n"; try { $action(); } catch (Error) { } } probe('new', fn () => new One()); probe('static call', fn () => Two::run()); probe('class constant', fn () => Three::CONSTANT); probe('class_exists', fn () => class_exists('Four')); probe('instanceof', fn () => new stdClass() instanceof Five); probe('::class', fn () => Six::class); probe('type declaration', function () { (fn (Seven $parameter) => 1)(1); });
new loader: One static call loader: Two class constant loader: Three class_exists loader: Four instanceof ::class type declaration
The first four operations called the loader and received the class name. The last three did not. instanceof knows that if the compared class does not exist, the object cannot be of that class, so it has no need to load it. ::class returns only the name resolved at compile time. A type declaration performs its check with the class name of the object; if a value of the wrong type arrives it throws a TypeError, but the loader is again not called. The consequence is this: a class is loaded only the first time it is really used. That also means that in a request where a code path never runs, the classes on that path are never loaded.
Multiple Loaders
spl_autoload_register can be called more than once. Registered loaders are called in registration order, and the chain stops as soon as the class is defined. If the third argument is true, the loader is added to the front of the queue. An application can carry the loaders of its dependencies in addition to its own; for this reason each loader should handle only its own prefix and leave a name it does not recognize alone, without raising an error.
<?php // order.php — loaders are called in registration order, the chain stops when the class is found spl_autoload_register(function (string $class): void { echo "first: $class\n"; }); spl_autoload_register(function (string $class): void { echo "second: $class\n"; if ($class === 'Target') { class_alias(stdClass::class, 'Target'); } }); spl_autoload_register(function (string $class): void { echo "third: $class\n"; }); spl_autoload_register(function (string $class): void { echo "front: $class\n"; }, true, true); echo "-- Target\n"; class_exists('Target'); echo "-- Missing\n"; class_exists('Missing');
-- Target front: Target first: Target second: Target -- Missing front: Missing first: Missing second: Missing third: Missing
front was registered last, but because true moved it to the front, it was called first. For Target the chain stopped at the second loader: it defined the name Target, and third was never called. For Missing, no loader defined the class, so all four were called in order and the class still could not be found. Every loader call has a cost; this is why it matters that a loader which does not recognize the prefix returns on its first line.
Mapping a Prefix to a Directory
The loader’s job is to turn the fully qualified name into a file path. PSR-4, the standards recommendation of the PHP community, binds this to a short rule: a prefix of the namespace corresponds to a base directory, the namespace parts left after the prefix become subdirectories, the last part is the file name, and .php is appended to the end. If the prefix Shop\ is mapped to the directory src/, the class Shop\Model\Product is loaded from the file src/Model/Product.php.
SP2. This lesson builds the loader with a single prefix and a single base directory. Mappings with several prefixes or fallback directories, and old class names that carry no namespace, are not measured.
The mapping is read in four steps: the prefix is removed, the remaining relative name is taken, the namespace separator is turned into the directory separator, and .php is appended. The driver below prints these four steps separately on the name Shop\Model\Product.
<?php // psr4.php — the PSR-4 mapping step by step $prefix = 'Shop\\'; $base = 'src/'; $class = 'Shop\Model\Product'; echo "full name : $class\n"; $relative = substr($class, strlen($prefix)); echo "relative name : $relative\n"; $path = str_replace('\\', '/', $relative); echo "path : $path\n"; echo "file : $base$path.php\n";
full name : Shop\Model\Product relative name : Model\Product path : Model/Product file : src/Model/Product.php
Each line derives from the previous one, and none of them looks at the file system: the mapping is purely a string operation. Whether the class is really defined in the file can be known only after require. For this reason a good loader checks that the file exists before require; if the file does not exist it raises no error and gives the next loader in the chain a chance. The loader.php of this lesson skips that check to keep the measurement plain.
The Measurement Setup
As in the previous lesson, reach.php carries the measure() function: it builds the same program before and after a change and runs both. It asks for the reach in order: if the exit code is not 0 the reach is breaking, if the output differs it is implicit, if the edited file is in the seam list it is at the seam, and if none of these holds it is in place. The earlier version must exit with 0, and the consumer file main.php is not edited.
<?php // reach.php — helpers that measure where a change reaches function write_files(string $dir, array $files): void { foreach ($files as $name => $content) { if ($content === null) { continue; // null: the file was deleted } $path = "$dir/$name"; if (!is_dir(dirname($path))) { mkdir(dirname($path), 0777, true); } file_put_contents($path, $content); } } function read_files(string $dir): array { $files = []; $walker = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS) ); foreach ($walker as $file) { $name = substr($file->getPathname(), strlen($dir) + 1); $files[$name] = file_get_contents($file->getPathname()); } return $files; } function run(string $dir): array { $command = 'cd ' . escapeshellarg($dir) . ' && php -d display_errors=0 main.php 2>/dev/null'; exec($command, $lines, $code); return [$code, implode("\n", $lines)]; } function measure(string $name, array $base, array $change, array $seams = []): string { if (array_key_exists('main.php', $change)) { throw new LogicException("$name: the consumer is not edited"); } write_files("measure/$name/before", $base); write_files("measure/$name/after", array_merge($base, $change)); [$codeBefore, $outputBefore] = run("measure/$name/before"); [$codeAfter, $outputAfter] = run("measure/$name/after"); if ($codeBefore !== 0) { throw new LogicException("$name: the baseline did not run"); } $reach = match (true) { $codeAfter !== 0 => 'breaking', $outputBefore !== $outputAfter => 'implicit', array_intersect(array_keys($change), $seams) !== [] => 'at-seam', default => 'in-place', }; printf( "%-18s files=%d exit=%-3d output %-7s -> %s\n", $name, count($change), $codeAfter, $outputBefore === $outputAfter ? 'same' : 'changed', $reach ); return $reach; }
The Move: Three Files, at the Seam
In the autoloading measurement, the class Shop\Model\Product lives in the file src/Model/Product.php, and loader.php looks for it under src/. The class moves to the base directory source/. Three files change: the old file is deleted (its value is null), the new one is added, and the loader is updated to point at the new directory. The fourth argument is the seam list and names loader.php as the seam. The counterpart is no-autoloader: the same move, made without updating the loader.
The loader.php in the baseline builds the mapping in three steps. If the incoming name starts with Shop\, it takes part; otherwise it leaves the name to another loader. In the remaining part after the prefix, Model\Product, the backslashes are turned into forward slashes and .php is appended. The resulting path, Model/Product.php, is searched for under the base directory src/. The base directory stands in the loader as a single string; all the knowledge of where the class files are loaded from is gathered there. This is why the move shows up only in this string, not in the consumer.
<?php // loading.php — a class file moves, and the loader is updated or not require_once 'reach.php'; // 9. A class file moves to another root directory $reaches[] = measure('autoloading', [ 'src/Model/Product.php' => <<<'PHP' <?php namespace Shop\Model; class Product { public function name(): string { return 'pen'; } } PHP, 'loader.php' => <<<'PHP' <?php spl_autoload_register(function (string $class): void { $prefix = 'Shop\\'; if (str_starts_with($class, $prefix)) { require 'src/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php'; } }); PHP, 'main.php' => <<<'PHP' <?php require 'loader.php'; echo (new Shop\Model\Product())->name(), "\n"; PHP, ], [ 'src/Model/Product.php' => null, 'source/Model/Product.php' => <<<'PHP' <?php namespace Shop\Model; class Product { public function name(): string { return 'pen'; } } PHP, 'loader.php' => <<<'PHP' <?php spl_autoload_register(function (string $class): void { $prefix = 'Shop\\'; if (str_starts_with($class, $prefix)) { require 'source/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php'; } }); PHP, ], ['loader.php']); echo run('measure/autoloading/before')[1], ' -> ', run('measure/autoloading/after')[1], "\n"; $before = read_files('measure/autoloading/before'); measure('no-autoloader', $before, [ 'src/Model/Product.php' => null, 'source/Model/Product.php' => $before['src/Model/Product.php'], ]);
autoloading files=3 exit=0 output same -> at-seam pen -> pen no-autoloader files=2 exit=255 output changed -> breaking
The first line is autoloading: three files changed, the exit code stayed 0, and the output was pen before and stayed pen. Because the output did not change and one of the edited files is in the seam list, the reach is at the seam: the change was absorbed at a single point and main.php was not edited. The second line is the counterpart: the same move, made without updating the loader, ended with two files but exited with 255, and the reach became breaking; the loader was still looking for the class under src/.
The consequence is this: the number of files does not decide the reach. The move that changed three files was trouble-free, and the move that changed two files stopped the program. What decides is whether the consumer was edited, and whether the single point that holds the binding is up to date.
The Bounding Measurement
The mapping carries the letters of the class name to the file name as they are. For the class Product, the loader asks for the file Product.php. What happens if the class file is renamed in lowercase as product.php? The driver measures this change on the same baseline, without touching the loader.
<?php // case.php — letter case in the file name is part of the mapping require_once 'reach.php'; $before = read_files('measure/autoloading/before'); measure('lowercase', $before, [ 'src/Model/Product.php' => null, 'src/Model/product.php' => $before['src/Model/Product.php'], ]);
lowercase files=2 exit=255 output changed -> breaking
The reach became breaking: two files changed and the exit code was 255. The loader asked for Product.php, and the directory held only product.php. This is a consequence of the file system, not of the language: the file system of the sandbox distinguishes upper and lower case. On a file system that does not distinguish case, the same change may work; the lesson did not measure that and prints only the sandbox result. For this reason the mapping also requires the file name to match the class name letter for letter.
Summary
- The reach of this lesson is at the seam. In the
autoloadingmeasurement, the class file moved to another root directory, three files changed, the output stayedpen, and the change was absorbed at the pointloader.php. - Its counterpart
no-autoloaderis breaking: a two-file move made without updating the loader exited with 255. The number of files does not decide the reach. - The loader is called with a fully qualified name for an unknown class; the PSR-4 prefix maps to a directory, the remaining namespace parts become subdirectories, and the last part becomes the file name.
- The loader is called only by operations that need the class to exist (
new, a static call, a class constant,class_exists);instanceof,::classand a type declaration do not call it. Several loaders run in registration order, and the chain stops when the class is found. - The bounding measurement: when the file was renamed
product.php, the reach in the sandbox was breaking; this is a consequence of the file system.
Next Step
The loader gathered in one place where a class comes from. The inside of the class, however, can still change behind an interface: if the consumer depends on an interface and the implementation changes, how many places are edited? The Common Interface Standards lesson measures this.
To keep your progress and take notes, Log in
My notes
Log in to take notes.