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 name | File |
|---|---|
Ledger\Report\InvoiceReport | src/Report/InvoiceReport.php |
Ledger\Report\RowSource | src/Report/RowSource.php |
Ledger\Report\InMemoryRowSource | src/Report/InMemoryRowSource.php |
Ledger\Billing\Invoice\Repository | src/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:
| Prefix | What 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.
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.