Skip to content
academia.sh

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 autoloading measurement, the class file moved to another root directory, three files changed, the output stayed pen, and the change was absorbed at the point loader.php.
  • Its counterpart no-autoloader is 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, ::class and 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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close