Skip to content
System nominalUTC --:--:--Articles 041
MODULE 03 Lesson 09 / 15 28 MIN

Functions, classes and namespaces

PSR-4 Autoloading and Dependency Direction

Map namespaces to files with PSR-4, keep your namespace yours, and reverse a dependency that points the wrong way before it hardens into architecture.

A project starts with one namespace because that is what the framework generated: App\, mapped to src/. Eighteen months later the team wants to reuse the invoicing code in a second application, and every one of the 340 files says App\Invoicing\. Renaming a namespace is a large mechanical change with import statements scattered through tests, and it is being done under deadline pressure because a decision nobody made deliberately has become expensive.

Two things make that story avoidable. The first is a rule that maps a class name to a path, deterministic enough that you can predict the file without opening the project. The second is a direction rule about which way dependencies point, because a class that constructs its own database connection has chosen its own context and will keep that choice forever.

Examples run on PHP 8.5.6, and the file paths in the output are shortened to /path/to/ because your working directory differs from mine. Nothing here needs a framework, and the only tool that helps is Composer, which is optional for the lab.

PSR-4 is a stripping rule, not a lookup table

The specification has one central idea. A contiguous prefix of the fully qualified class name is mapped to a base directory, that prefix is removed, and the remainder becomes a relative path with namespace separators replaced by directory separators and .php appended. The authority for the details is the PSR-4 specification itself, and the two rules people get wrong are both in it: the file name must match the case of the class name, and the prefix does not appear again inside the mapped path.

Given the rule "Ledger\\": "src/", the mapping is mechanical:

Fully qualified class nameFile
Ledger\Report\InvoiceReportsrc/Report/InvoiceReport.php
Ledger\Report\RowSourcesrc/Report/RowSource.php
Ledger\Report\InMemoryRowSourcesrc/Report/InMemoryRowSource.php
Ledger\Billing\Invoice\Repositorysrc/Billing/Invoice/Repository.php

Composer's documentation states the consequence directly: the prefix Foo\\ pointing at src/ means a class named Foo\Bar\Baz is loaded from src/Bar/Baz.php. No Foo directory appears in that path. If you have used PSR-0, this is the difference that matters, since PSR-0 kept the whole namespace in the path and turned underscores into separators.

The rule is small enough to implement by hand, which is worth doing once so autoloading stops being a black box. This is the loop that answers "where is this class?" on every request that touches a class not yet loaded:

<?php
declare(strict_types=1);

spl_autoload_register(static function (string $class): void {
    $prefix = 'Ledger\\';

    if (!str_starts_with($class, $prefix)) {
        return;
    }

    $relative = substr($class, strlen($prefix));
    $path = __DIR__ . '/src/' . str_replace('\\', '/', $relative) . '.php';

    if (is_file($path)) {
        require $path;
    }
});

Run a script that requires that file and uses two classes, and the rule is confirmed by the engine rather than by the prose:

<?php
declare(strict_types=1);

require __DIR__ . '/bootstrap.php';

use Ledger\Report\InMemoryRowSource;
use Ledger\Report\InvoiceReport;

$source = new InMemoryRowSource([
    ['number' => 'INV-1', 'cents' => 129900],
    ['number' => 'INV-2', 'cents' => 5000],
]);

echo (new InvoiceReport($source))->totalCents(), PHP_EOL;
echo (new ReflectionClass(InvoiceReport::class))->getFileName(), PHP_EOL;
134900
/path/to/ledger/src/Report/InvoiceReport.php

InvoiceReport was never required by name. The autoloader was called with Ledger\Report\InvoiceReport, stripped the prefix, built src/Report/InvoiceReport.php, and included it. Two interface details from the specification are worth copying when you write your own: an autoloader must not throw exceptions and must not raise errors, and it should not return a value. The callback here returns quietly for classes outside its prefix, which is what lets several autoloaders coexist on the stack.

composer.json holds the rule, Composer holds the index

You rarely write the loader. You write the rule, and Composer generates loaders from it:

{
    "name": "acme/ledger",
    "type": "project",
    "require": {
        "php": ">=8.3"
    },
    "autoload": {
        "psr-4": {
            "Ledger\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "Ledger\\Tests\\": "tests/"
        }
    }
}

Three details in that block carry weight. The namespace key must end in \\, because Foo would also match the unrelated namespace FooBar. The paths are relative to the package root, not to src/. And the test namespace lives under autoload-dev, which is not loaded in production, so test classes cannot accidentally become part of the shipped surface. Composer builds a merged map from every installed package and writes it into vendor/composer/autoload_psr4.php; the entry point your code requires is vendor/autoload.php. The rule you wrote does not appear anywhere else.

The practical consequence is that adding a class needs no command at all. PSR-4 resolves by rule at runtime, so a new file in the right directory is found the first time it is referenced. Composer caches paths where it can, so a class in a directory that did not exist when the loader was generated is the case that sends you to composer dump-autoload, which rebuilds the index without touching installed packages.

Case is part of the contract

The specification requires the file name to match the case of the class name. A file at src/report/InvoiceReport.php declaring Ledger\Report\InvoiceReport works on a case-insensitive filesystem, which includes the default on macOS, and fails on a case-sensitive one. It is the most common cause of a class that loads locally and not in production. If the class name in the error is spelled exactly right, look at the file name next.

Your namespace should say what the code is for

Nothing in PSR-4 prevents "Symfony\\": "src/" or "WooCommerce\\": "src/". Both are legal, both autoload, and both are a mistake for the same reason: they claim a top-level vendor name that belongs to somebody else. A namespace prefix is a global name. Your classes are the only ones that may live under yours.

The weaker version of the same mistake is App\, which the framework generates because it is short and it is inside the application. It is fine while the code stays inside the application. The moment you want a library, a shared package, or the same invoicing code in a second project, App\ is meaningless in the new context and every file must change. The prefix you actually want names the owner and the product, something like Acme\Ledger\ or Ledger\, because that name is still true if the framework under it changes.

What a framework-shaped namespace costs, concretely:

PrefixWhat happens when you move or share the code
App\Invoicing\ Every namespace and import changes. Tests change with them. The package cannot be published under that name.
Symfony\Invoicing\ You now collide with the vendor's own namespace space and cannot publish the package at all.
Acme\Invoicing\ The prefix survives the framework change. You register it in the new project's composer.json and nothing else moves.

This is not a runtime failure. Code with a framework-named prefix runs perfectly today. It is a decision about which name stays true over time, and the only time it is cheap to make is before there are 340 files.

A class that builds its own dependency has chosen its context

Here is a report class doing something completely normal:

<?php
declare(strict_types=1);

namespace Ledger\Report;

final class InvoiceReport
{
    private \PDO $pdo;

    public function __construct()
    {
        $this->pdo = new \PDO('sqlite::memory:');
        $this->pdo->exec('CREATE TABLE invoices (number TEXT, cents INTEGER)');
        $this->pdo->exec("INSERT INTO invoices VALUES ('INV-1', 129900), ('INV-2', 5000)");
    }

    public function totalCents(): int
    {
        $statement = $this->pdo->query('SELECT cents FROM invoices');
        $total = 0;

        foreach ($statement as $row) {
            $total += (int) $row['cents'];
        }

        return $total;
    }
}

echo (new InvoiceReport())->totalCents(), PHP_EOL;
134900

It runs and it prints the right number. It is also untestable without SQLite, it cannot tell you how many invoices it summed without a second query, it has hard-coded the fact that invoices are rows, and it constructs its own PDO, so no caller can supply a different connection. That last point is the dependency direction: the class points at a concrete database handle. A unit test cannot get in the way of that.

Invert it with an interface the class defines for its own use, and a concrete class that implements it:

<?php
declare(strict_types=1);

namespace Ledger\Report;

interface RowSource
{
    /** @return list<array{number: string, cents: int}> */
    public function rows(): array;
}

final class InvoiceReport
{
    public function __construct(private RowSource $source)
    {
    }

    public function totalCents(): int
    {
        return array_sum(array_column($this->source->rows(), 'cents'));
    }
}
<?php
declare(strict_types=1);

namespace Ledger\Report;

final class InMemoryRowSource implements RowSource
{
    public function __construct(private array $rows)
    {
    }

    public function rows(): array
    {
        return $this->rows;
    }
}

$source = new InMemoryRowSource([
    ['number' => 'INV-1', 'cents' => 129900],
    ['number' => 'INV-2', 'cents' => 5000],
]);

echo (new InvoiceReport($source))->totalCents(), PHP_EOL;
134900

Two things changed and one did not. The interface declares the shape the report needs, which is a list of rows with a number and an amount, and nothing about SQL. The report receives the dependency instead of building it, and the test double is a five-line class with no database behind it. What did not change is the output. The refactor is safe, mechanical, and entirely about which direction the arrow points.

The rule is that dependencies point inward, toward the code that holds the decisions. InvoiceReport owns the rule about what a total is, so it should not also own the decision about where rows come from. The PdoRowSource you write next depends on the interface and on PDO, which means the unstable part, the database, sits at the edge where it can be replaced. If the interface had been defined by PdoRowSource instead, the report would import the database class to type against it and the arrow would still point outward.

final and readonly remove options on purpose

Both keywords are design decisions about what may happen to your code later, and both are cheap now and expensive to add afterwards. final on a class says no subclass may exist. The engine enforces it at compile time:

PHP Fatal error:  Class CacheConfigChild cannot extend final class CacheConfig in ... on line 12

What that buys is that the class's behaviour is exactly what you can read, and no subclass exists anywhere that overrides one method and inherits the rest. What it costs is the test double. If a test suite mocks concrete classes, marking a service final breaks it immediately, and the fix is usually to depend on an interface like RowSource above instead of mocking the class. That is the argument for final rather than against it: it pushes tests toward the contract they should have been using.

readonly says a property may be written once, from the scope that declares it. On a class, it applies that rule to every property and forbids static properties, which is a workable definition of a value object:

<?php
declare(strict_types=1);

final readonly class Money
{
    public function __construct(
        public int $cents,
        public string $currency = 'EUR',
    ) {
    }

    public function withCents(int $cents): self
    {
        return new self($cents, $this->currency);
    }
}

echo (new Money(129900))->withCents(5000)->cents, PHP_EOL;
5000

Attempting to write $money->cents = 1 from outside fails with Error: Cannot modify readonly property Money::$cents, and the withCents() method is the only way to produce a changed value. The judgement call is whether the thing is a value. Money, an invoice number, a date range and a configuration object loaded from the environment are values, and immutability removes a whole category of bug where one part of the request mutates what another part is reading. A customer row, a cart and a request object are not; they change state over a request, and making them readonly moves the problem into withX() methods that rebuild everything.

Use final as the default for anything not explicitly designed for subclassing, since adding inheritance later is a feature you can grant and removing it is a breaking change. Use readonly where the invariant is that a value never changes, and nowhere else.

Lab 30 minutes

Build a small project called ledger using only PHP, no packages.

Create composer.json with a autoload.psr-4 rule mapping Ledger\\ to src/ and an autoload-dev.psr-4 rule mapping Ledger\\Tests\\ to tests/. Do not install Composer packages.

Write bootstrap.php with a hand-rolled spl_autoload_register() that implements the same PSR-4 rule. Then write src/Report/RowSource.php (an interface with rows(): array), src/Report/InMemoryRowSource.php, and src/Report/InvoiceReport.php with a constructor that takes a RowSource and a totalCents() method. Write run.php that requires bootstrap.php, builds an in-memory source, prints the total, and prints the file path of InvoiceReport using ReflectionClass.

Now break it twice and record the exact error each time. First, move InMemoryRowSource.php to src/Report/InMemory.php without renaming the class. Second, restore the file, then change the prefix in bootstrap.php to Acme\\. Undo both changes when you are done.

Show the solution and the reasoning

The layout mirrors the namespace, and the two prefixes stay separate so test classes never ship:

ledger/
  bootstrap.php
  run.php
  composer.json
  src/
    Report/
      InMemoryRowSource.php
      InvoiceReport.php
      RowSource.php
  tests/
    Report/
      InvoiceReportTest.php
<?php
declare(strict_types=1);

spl_autoload_register(static function (string $class): void {
    $prefix = 'Ledger\\';

    if (!str_starts_with($class, $prefix)) {
        return;
    }

    $relative = substr($class, strlen($prefix));
    $path = __DIR__ . '/src/' . str_replace('\\', '/', $relative) . '.php';

    if (is_file($path)) {
        require $path;
    }
});
<?php
declare(strict_types=1);

namespace Ledger\Report;

interface RowSource
{
    /** @return list<array{number: string, cents: int}> */
    public function rows(): array;
}
<?php
declare(strict_types=1);

namespace Ledger\Report;

final class InvoiceReport
{
    public function __construct(private RowSource $source)
    {
    }

    public function totalCents(): int
    {
        return array_sum(array_column($this->source->rows(), 'cents'));
    }
}
134900
/path/to/ledger/src/Report/InvoiceReport.php

The reflection line is the part that confirms the rule rather than the output. It reports the file the engine actually included, so it proves the class came from src/Report/InvoiceReport.php and not from a stray require. Keep that habit when an autoload problem is hard to see.

The first break is the mistake everyone makes. Renaming the file to InMemory.php while the class is still InMemoryRowSource means the autoloader builds a path that does not exist, returns, and PHP reports Error: Class "Ledger\Report\InMemoryRowSource" not found. Note what the message does not say. It does not mention the file, because from the engine's point of view no autoloader claimed the class. Your first move is to apply the rule by hand and check whether that path exists.

The second break is more interesting. Changing the prefix to Acme\\ means the callback sees Ledger\Report\InvoiceReport, fails the str_starts_with() check, returns, and the class is not found. The rule and the files are both fine; they no longer agree about the prefix. That is the situation the namespace choice in this lesson is about, scaled up: the prefix and the code have to move together, and the cost of that move is proportional to how deeply the prefix appears in use statements and namespace declarations across the project.

Progress

Mark this lesson done and it stays marked in this browser.