---
title: 'The Web Application Framework'
source: 'https://academia.sh/en/courses/web-application-framework'
language: en
updated: '2026-10-21T05:03:38+00:00'
license: 'CC BY-SA 4.0'
lessons: 21
outcomes:
    - 'Measures with a request log whether each expected piece ran, was skipped, was deferred or was not bound in a request'
    - 'Builds the path from a request to a response with routing, controllers and middleware'
    - 'Defines models and their relationships with object–table binding and tells lazy loading from eager loading'
    - 'Moves work out of the response with queues, events and scheduled tasks'
    - 'Finds a piece that was silently left unbound behind a successful response'
---

# The Web Application Framework

With a framework, you write the code and the framework calls it. Did the piece you wrote run in this request? Some pieces run inside the request, some are skipped because a layer in front of them answered early, some are deferred to a queue or to after the response, and some are never bound to the request by any registration and raise no error either. The course measures routing, controllers, middleware, the object container, templates, validation, object–table binding, queues, events and scheduled tasks with a single question — did this piece run in this request?

## Framework Fundamentals

Inversion of control, the request–response lifecycle, routing, controllers, middleware, the object container and facades — each as a mechanism that decides whether a piece you wrote is called in this request.

### Why a Framework

Source: https://academia.sh/en/courses/web-application-framework/why-a-framework

A comparison of an if chain whose flow the script drives with a route registration whose flow the framework drives, and a twelve-part measurement built on four names that classify whether a part ran in this request.

The Object-Oriented PHP and Standards course split code across several files and several authors and measured what a user of a definition experiences when that definition changes. This course places code inside a framework and reverses the relationship: the developer writes the parts, and the framework calls them. This inversion of control raises the question asked most often when working with a framework: why did the part I wrote not run? Across twenty-one lessons the course measures every mechanism with one question: **did this part run in this request?**

#### Who Drives the Flow

In a script without a framework, the script itself drives the flow. Which request calls which function is decided in lines the caller wrote.

```php
<?php
// manual.php — the script itself drives the flow
function form(): string { return 'empty form'; }
function postList(): string { return 'post list'; }

$path = '/posts/new';

if ($path === '/posts') {
    echo postList(), "\n";
} elseif ($path === '/posts/new') {
    echo form(), "\n";
} else {
    echo "404\n";
}
```

```
empty form
```

**FF1.** The script's own `if` chain made the call to `form()`. Adding a new path means editing the chain, and the order of the chain is the only thing that decides which function is called. In a framework this relationship is reversed: the developer **registers** the `form` action together with a pattern, and the code that tries the pattern against the request and calls the action lives inside the framework. The part written is no longer the caller but the callee. Because the framework makes the call, the decision about whether a part was called is also made somewhere the developer cannot see. That is why the question is worth measuring.

#### The Measurement Core

The measurement itself lives in `ran.php` and does not change for the rest of the course. The same twenty-two lines are given again in every lesson.

```php
<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}
```

The `Ledger` class keeps three lists: the parts placed on the request's **path**, the parts **called** within the request, and the parts left **outside** the request. `newRequest()` empties all three, and every measurement begins with it. A mechanism attaches itself to the core with three calls: `Ledger::onPath()` when it places a part on the path, `Ledger::ran()` when it calls a part, and `Ledger::defer()` when it leaves a part outside the request. `classify()` takes the name of a part and looks at these three lists **in order**.

**FF2.** The decision order is `classify()` itself. If the part is in the `$ran` list, it **ran**: it was called during the request and the response waited for it. If not, and it is in the `$deferred` list, it is **deferred**: it was registered but did not run in the request, and it will run after the response or in a worker. If not, and it is in the `$path` list, it is **skipped**: the part was on the request's path, but a step before it produced the response early. If it is in none of the three, it is **not wired**: no registration attached the part to this request, and no error was raised. The order is written as a rule; if a part were in two lists at once, the first match would win. In this lesson no part is in two lists at once.

One condition is part of the classification: **the developer names the expected part.** What is measured is not every registration in the framework but the parts the developer expects to run in this request, and `classify()` takes that name and reports what happened to it. Every part a lesson measures is named beforehand.

Two distinctions need to be made at the outset. In the Object-Oriented PHP and Standards course, the effect of a change to a definition on its users was measured with two runs, before and after; here there is a single run, and the question is whether a call happened. In the PHP Fundamentals course, the silent outcome meant a fault that gave no signal; **not wired** is not a fault but a missing match: the registration exists and has just not been attached to this request. No signal is measured; `Ledger` is.

#### The Framework Model

`framework.php` is a small model of a framework. In this course the framework is taught through this model rather than through a product name; it is written in full only in this first lesson, and later lessons build the slice that belongs to their own mechanism.

```php
<?php
// framework.php — a model of a small web application framework
declare(strict_types=1);
require_once 'ran.php';

final class Request {
    public array $params = [];
    public function __construct(
        public string $method, public string $path,
        public array $body = [], public bool $session = false,
    ) {}
}

final class Response {
    public function __construct(public int $status, public string $body = '') {}
}

final class ObjectContainer {
    private array $recipes = [];
    private array $singletons = [];
    public function bind(string $name, Closure $recipe, bool $singleton = false): void {
        $this->recipes[$name] = [$recipe, $singleton];
    }
    public function get(string $name): object {
        if (isset($this->singletons[$name])) return $this->singletons[$name];
        if (isset($this->recipes[$name])) {
            [$recipe, $singleton] = $this->recipes[$name];
            $object = $recipe($this);
            if ($singleton) $this->singletons[$name] = $object;
            return $object;
        }
        $dependencies = [];
        foreach ((new ReflectionClass($name))->getConstructor()?->getParameters() ?? [] as $p) {
            $dependencies[] = $this->get($p->getType()->getName());
        }
        return new $name(...$dependencies);
    }
}

final class Queue {
    private array $pending = [];
    public function push(string $part, Closure $job): void {
        Ledger::defer($part);
        $this->pending[] = [$part, $job];
    }
    public function process(): array {         // worker: runs independently of the request
        $done = [];
        while ($entry = array_shift($this->pending)) {
            [$part, $job] = $entry;
            $job();
            $done[] = $part;
        }
        return $done;
    }
}

final class Events {
    private array $listeners = [];
    public function __construct(private Queue $queue) {}
    public function listen(string $event, string $part, Closure $job, bool $queued = false): void {
        $this->listeners[$event][] = [$part, $job, $queued];
    }
    public function dispatch(string $event): void {
        foreach ($this->listeners[$event] ?? [] as [$part, $job, $queued]) {
            if ($queued) { $this->queue->push($part, $job); continue; }
            Ledger::ran($part);
            $job();
        }
    }
}

final class App {
    private array $routes = [];
    private array $after = [];             // to run after the response is sent
    public function __construct(public ObjectContainer $container) {}
    public function route(string $method, string $pattern, string $part, Closure $action, array $middleware = []): void {
        $this->routes[] = [$method, $pattern, $part, $action, $middleware];
    }
    public function afterResponse(string $part, Closure $job): void {
        Ledger::defer($part);
        $this->after[$part] = $job;
    }
    private function matchRoute(Request $request): ?array {
        foreach ($this->routes as $route) {        // the first match wins
            $regex = '#^' . preg_replace('#\{(\w+)\}#', '(?P<$1>[^/]+)', $route[1]) . '$#';
            if ($route[0] === $request->method && preg_match($regex, $request->path, $m)) {
                $request->params = array_filter($m, 'is_string', ARRAY_FILTER_USE_KEY);
                return $route;
            }
        }
        return null;
    }
    public function handle(Request $request): Response {
        Ledger::newRequest();
        $this->after = [];
        $route = $this->matchRoute($request);
        if ($route === null) return new Response(404);
        [, , $part, $action, $middleware] = $route;
        foreach ([...array_column($middleware, 0), $part] as $p) Ledger::onPath($p);
        $pipeline = function (Request $r) use ($part, $action): Response {
            Ledger::ran($part);
            return $action($r, $this->container);
        };
        foreach (array_reverse($middleware) as [$name, $layer]) {
            $pipeline = function (Request $r) use ($name, $layer, $pipeline): Response {
                Ledger::ran($name);
                return $layer($r, $pipeline);
            };
        }
        return $pipeline($request);
    }
    public function finish(): array {          // the response went to the client
        foreach ($this->after as $job) $job();
        return array_keys($this->after);
    }
}
```

The file consists of six parts, and each later lesson opens one of them. `Request` and `Response` carry the request and the response. `ObjectContainer` is a factory that builds an object from a class name; it builds an unregistered class from the types of its constructor parameters with `ReflectionClass`, and it resolves only class-typed parameters. `Queue` leaves jobs outside the request; the `process()` call is the worker, and the model runs in the same process. `Events` calls the listeners registered for an event name or puts them on the queue. `App` registers routes, tries them in registration order with `matchRoute()` where **the first match wins**, and then wraps the middleware into a nested chain and calls it.

**FF3.** Inverted control is visible inside `handle()`. The action is called on the line `$action($r, $this->container)`, and the caller is the framework; the `Ledger::ran($part)` record is not in the action itself but right before the framework's call line. The measurement is not written into the parts but into the mechanism that calls them: a part does not announce that it ran, the framework announces that it called the part.

#### Four Requests, Twelve Parts

`requests.php` builds an object container, registers three routes and three listeners, and handles four requests in order. The expected parts of each request are named together with the request.

```php
<?php
// requests.php — four requests, twelve expected parts
require 'framework.php';

$container = new ObjectContainer();
$container->bind(Queue::class, fn() => new Queue(), singleton: true);
$container->bind(Events::class, fn(ObjectContainer $c) => new Events($c->get(Queue::class)), singleton: true);
$app = new App($container);

$session = ['session', fn(Request $r, Closure $next) =>
    $r->session ? $next($r) : new Response(302, '/login')];
$validation = ['validation', fn(Request $r, Closure $next) =>
    ($r->body['title'] ?? '') !== '' ? $next($r) : new Response(422, 'title required')];

$app->route('GET', '/posts/{id}', 'show', fn(Request $r) => new Response(200, "post {$r->params['id']}"));
$app->route('GET', '/posts/new', 'form', fn() => new Response(200, 'empty form'));
$app->route('POST', '/posts', 'save', function (Request $r, ObjectContainer $c) use ($app) {
    $c->get(Events::class)->dispatch('post.saved');
    $c->get(Queue::class)->push('thumbnail', fn() => null);
    $app->afterResponse('statistics', fn() => null);
    return new Response(201, 'saved');
}, [$session, $validation]);

$events = $container->get(Events::class);
$events->listen('post.saved', 'cache', fn() => null);
$events->listen('post.saved', 'notify', fn() => null, queued: true);
$events->listen('post.created', 'index', fn() => null);

$requests = [
    [new Request('GET', '/posts/new'), ['form']],
    [new Request('POST', '/posts', ['title' => 'Hello']), ['session', 'validation', 'save']],
    [new Request('POST', '/posts', ['title' => ''], true), ['validation', 'save']],
    [new Request('POST', '/posts', ['title' => 'Hello'], true),
        ['save', 'cache', 'notify', 'thumbnail', 'statistics', 'index']],
];

$tally = ['ran' => 0, 'skipped' => 0, 'deferred' => 0, 'not wired' => 0];
foreach ($requests as [$request, $expected]) {
    $response = $app->handle($request);
    printf("%s %s -> %d %s\n", $request->method, $request->path, $response->status, $response->body);
    foreach ($expected as $part) {
        $outcome = classify($part);
        $tally[$outcome]++;
        printf("  %-11s %s\n", $part, $outcome);
    }
    if ($after = $app->finish()) echo "  after response: ", implode(', ', $after), "\n";
}
echo "worker: ", implode(', ', $container->get(Queue::class)->process()), "\n";
foreach ($tally as $outcome => $n) echo "$outcome $n\n";
```

```
GET /posts/new -> 200 post new
  form        not wired
POST /posts -> 302 /login
  session     ran
  validation  skipped
  save        skipped
POST /posts -> 422 title required
  validation  ran
  save        skipped
POST /posts -> 201 saved
  save        ran
  cache       ran
  notify      deferred
  thumbnail   deferred
  statistics  deferred
  index       not wired
  after response: statistics
worker: notify, thumbnail
ran 4
skipped 3
deferred 3
not wired 2
```

**FF4.** Of the twelve expected parts, **4 ran, 3 were skipped, 3 were deferred and 2 were not wired.** This distribution is the binding figure of the course, and it comes from this output. No later lesson changes it; each lesson measures its own slice, and the closing lesson writes two separate figures separately: the twelve parts of the core and the outcomes of the twenty-one lessons themselves.

The output exposes three outcomes through the status code and does not expose the fourth. In the second request `session` ran and returned 302; `validation` and `save` behind it were on the path but were never called, so both were **skipped**. In the third request `validation` ran and returned 422, and `save` was skipped again. A skipped part gives itself away through a code such as 302 or 422. In the fourth request `notify` and `thumbnail` ran in the worker, and `statistics` ran after the response was sent; none of the three is inside the 201, because the response did not wait for them. A deferred part shows up in a worker.

**Not wired is the most dangerous outcome**, because it shows up nowhere. The first request went to `/posts/new` and `form` was never called: the response is 200 with the body `post new`. The `form` route was registered, but because the `/posts/{id}` pattern was registered first, the request fitted that one. In the fourth request the `index` listener had been registered under `post.created`, while the action dispatched `post.saved`: the listener was not called, and the response is 201. In both cases the response is successful, and the problem shows only in `Ledger`.

This lesson's outcome is **ran**. In the fourth request the `save` action and the `cache` listener were called before the response was sent: this is the flow the framework promises.

#### The Bounding Measurement

The classification depends on the name of the part the developer expects. If a part really ran but the expected name does not match the registered name, does the outcome change? The same request asks about two names: the registered `save` and `saves`, which is one letter different.

```php
<?php
// naming.php — the developer names the expected part
require 'framework.php';

$app = new App(new ObjectContainer());
$app->route('POST', '/posts', 'save', fn() => new Response(201, 'saved'));

$response = $app->handle(new Request('POST', '/posts'));
echo $response->status, "\n";
foreach (['save', 'saves'] as $part) {
    printf("%-9s %s\n", $part, classify($part));
}
```

```
201
save      ran
saves     not wired
```

**FF5.** The same request, the same response, a single call: `save` **ran** and `saves` is **not wired**. The action was called the same number of times for both names, but `Ledger` knew only the registered name. So not wired is not always a matching error; the expected name may just have been misspelled. Because the classification questions the developer's expectation and not the framework's registrations, the reliability of the measurement depends on the correctness of the naming.

#### Summary

- In a framework, control is inverted: the part written is registered and the framework calls it, so the caller is no longer the developer.
- `classify()` classifies a part in order as ran, deferred, skipped or not wired; the developer names the expected part.
- On four requests and twelve expected parts the core gave 4 ran, 3 skipped, 3 deferred and 2 not wired; this distribution is the binding figure of the course.
- A skipped part shows up through a status code and a deferred part shows up in a worker; a part that is not wired shows up nowhere while the response is 200 or 201.
- This lesson's outcome is **ran**: the `save` action and the `cache` listener of the fourth request were called before the response.

#### Next Step

In the fourth request, three parts were not inside the response: `notify` and `thumbnail` ran in the worker, and `statistics` ran after the response was sent. The next lesson, The Request–Response Lifecycle, asks a single question: what does the framework still run after the response has gone to the client, and does that leave any trace in the body of the response?

### The Request–Response Lifecycle

Source: https://academia.sh/en/courses/web-application-framework/request-response-lifecycle

The boot phases of service providers, the way the kernel handles a request, and the way work that runs after the response is sent stays outside the response; a comparison of deferred work with work called directly.

The previous lesson showed that in the fourth request three parts were not inside the response: two ran in the worker and one ran after the response was sent. This lesson follows when work runs, in order, over the life of one request. A framework does not treat a request as a single call; it divides it into boot, handling and sending phases, and each phase has its own records. If the order of the phases is not known, it is also impossible to understand why a part does not appear in this request.

#### The Phases of a Request

In an application written with a framework, the life of a request is divided into four separate phases, and each phase has a different owner.

| Phase | What happens | Whose code |
|---|---|---|
| Boot | Providers are registered and booted | The application's providers |
| Handling | The kernel finds the action that serves the request and calls it | The framework calls, the developer writes the action |
| Sending | The response returned by the action is handed to the client | The framework |
| After the response | Registered jobs run | A job the developer registered, a call the framework makes |

The boundary between the third and fourth rows of the table is the subject of this lesson. At the moment the client receives the response, the framework may still hold jobs that have not run. It is easy to see that these jobs do not mix into the content of the response; the real question is how we can know whether they actually ran.

#### Boot and Service Providers

Boot is the phase in which the framework sets itself up before it handles the first request. In this phase the framework calls the **service providers**: each provider is a registration unit that introduces one part of the application to the framework. A provider's work is divided into two phases. In the **register** phase the provider only declares what it offers and does not touch what another provider offers. In the **boot** phase all providers have been registered, so the provider can now rely on what the others offer. The phases do not run per provider but for all providers in turn: first everyone's registration, then everyone's boot.

The `lifecycle.php` below is a small model of this lifecycle. Two of the registration calls in `Kernel` are the heart of the model: `provider()` registers a provider's register and boot functions, and `action()` registers the part that will be called at the end of the request. `boot()` runs the two phases in order, `handle()` handles the request, and `finish()` is called after the response has gone to the client.

```php
<?php
// lifecycle.php — a small lifecycle with service providers
declare(strict_types=1);
require_once 'ran.php';

final class Response {
    public function __construct(public int $status, public string $body = '') {}
}

final class Kernel {
    public array $phases = [];
    private array $providers = [];
    private array $watchers = [];
    private array $after = [];
    private ?array $action = null;
    public function provider(Closure $register, Closure $boot): void {
        $this->providers[] = [$register, $boot];
    }
    public function watcher(string $part, Closure $job): void { $this->watchers[$part] = $job; }
    public function action(string $part, Closure $job): void { $this->action = [$part, $job]; }
    public function afterResponse(string $part, Closure $job): void {
        Ledger::defer($part);
        $this->after[$part] = $job;
    }
    public function boot(): void {
        foreach ($this->providers as [$register]) $register($this);     // phase 1: register
        foreach ($this->providers as [, $boot]) $boot($this);           // phase 2: boot
    }
    public function handle(): Response {
        Ledger::newRequest();
        $this->after = [];
        foreach ($this->watchers as $part => $job) {
            Ledger::onPath($part); Ledger::ran($part); $job();
        }
        [$part, $job] = $this->action;
        Ledger::onPath($part); Ledger::ran($part);
        return $job($this);
    }
    public function finish(): array {          // the response went to the client
        foreach ($this->after as $job) $job();
        return array_keys($this->after);
    }
}
```

This class attaches to the `ran.php` core with three calls. The same twenty-two lines are given again in every lesson.

```php
<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}
```

**FF6.** `boot()` is two separate loops, and this is deliberate. The second loop does not start until the first has finished, so provider B's registration runs before provider A's boot. In its boot, A can rely on what B offers, and the reverse is also true. With a single loop, the order of the providers would break this assurance. `handle()` is independent of the `boot()` call: the model stands for a process in which boot runs once and many requests are then handled. It would also be wrong to make a provider depend, in the register phase, on what another provides: at that moment the other provider may not have registered yet, and nothing has been bound. In the register phase a provider only declares; use is left to the boot phase.

In the C Fundamentals course, the compilation stages (preprocessing, compilation, assembly, linking) were separate steps that ran before the program ran. There is no such separation here: boot, register, handling and the after-response phase all run at run time, and all four outcomes are at run time. **Deferred is not a stage; it is the work being left outside the response.**

#### Work That Runs After the Response

A controller sometimes wants a piece of work that must not delay the response: incrementing a counter, writing a statistics row. If the work does not affect the content of the response, there is no reason for the client to wait for it. For this the framework offers `afterResponse()`: the work is registered, the response is built and sent to the client, and then `finish()` runs it. The `statistics` job is measured in two ways. In one, the action leaves the job with `afterResponse()`; in the other, it calls the job directly and reports it with `Ledger::ran()`.

```php
<?php
// request-lifecycle.php — work that runs after the response stays outside the response
declare(strict_types=1);
require_once 'lifecycle.php';

function build(bool $direct): Kernel {
    $k = new Kernel();
    $k->provider(
        fn(Kernel $k) => $k->phases[] = 'register A',
        fn(Kernel $k) => $k->phases[] = 'boot A',
    );
    $k->provider(
        fn(Kernel $k) => $k->phases[] = 'register B',
        fn(Kernel $k) => $k->phases[] = 'boot B',
    );
    $k->action('save', function (Kernel $k) use ($direct): Response {
        $job = fn() => null;
        if ($direct) { Ledger::ran('statistics'); $job(); }
        else { $k->afterResponse('statistics', $job); }
        return new Response(201, 'saved');
    });
    $k->boot();
    return $k;
}

foreach ([false, true] as $direct) {
    $k = build($direct);
    echo implode(', ', $k->phases), "\n";
    $response = $k->handle();
    printf("%d %s | statistics %s\n", $response->status, $response->body, classify('statistics'));
    echo "after response: ", implode(', ', $k->finish()) ?: '-', "\n";
}
```

```
register A, register B, boot A, boot B
201 saved | statistics deferred
after response: statistics
register A, register B, boot A, boot B
201 saved | statistics ran
after response: -
```

**FF7.** The response of the two runs is the same: 201 and `saved`. There is not a single trace of `statistics` in the body; the only thing that separates the two is which list `Ledger` found the job in. The job left with `afterResponse()` entered the `$deferred` list through the `defer()` call, had still not run when `handle()` returned, and `finish()` ran it after that. The job called directly ran before the response was built, inside the action.

In the model, sending is the `handle()` call returning a `Response`, and `finish()` comes after it. On a real server, sending is the output being handed to the client and the connection being closed; work that runs after the response runs in the same process and the same memory, but when the client is no longer waiting. This differs from the previous lesson's worker: `Queue` leaves the job somewhere another process can pick it up, and its link to the request's process is cut, whereas `afterResponse()` stays inside the request's process. Both are under deferred, but their failure modes differ; if the process shuts down right after the response, the registered job may never run.

That the same work falls under two separate outcomes shows that the outcome depends not on the work itself but on where the work was registered. The decision order (ran, deferred, skipped, not wired) applies here too: in the first run `statistics` is not in the `$ran` list but in the `$deferred` list; in the second it is in the `$ran` list, and the order ends there. The developer named the expected part: the same name, `statistics`, was queried in both runs.

This lesson's outcome is **deferred**. Deferred work is not lost, and it runs outside the response; but if an error occurs, the response has already gone as 201 and the error does not show in the result of the request. The order of danger among the outcomes applies here too. A skipped part shows itself through a status code, a deferred part shows itself in a worker or after `finish()`; the most dangerous outcome is still **not wired**, because it shows nowhere. Whether deferred work is not wired, that is, whether the `afterResponse()` call was ever made, is again told only by the name the developer expected.

#### Which Work Is Left Until After the Response

Leaving a piece of work until after the response is not always right. The question that makes the distinction is whether the response depends on the result of that work. If the action saves a record and returns the record's number in the response, the number comes from the record itself and the work must be called directly. If the response does not depend on the result of the work, as with a statistics row, the work can be left until after the response and the client does not wait for it. If the work fails later, there is no response left to tell the client, so the failure of the deferred work must be announced through another channel, a log or an alert; choosing that channel is not the subject of this lesson.

The two runs also share one point: the action was called in both cases and returned 201 in both cases. The response code gives no information about whether the work ran. The work itself is looked up in `Ledger`. So when a developer asks "was the statistics row written", the response being 201 is not the answer; the answer is which list the work is found in.

#### The Bounding Measurement

Boot runs once, requests many times. Does a part set up in a provider's boot phase run in every request? We measure this with two parts: `loadSettings`, which is called only once in the boot phase, and `timer`, which is registered in the same phase and then called in every request. Two requests are handled, and in each the two names are classified.

```php
<?php
// bootstrap.php — does a part set up during boot run in every request
declare(strict_types=1);
require_once 'lifecycle.php';

$k = new Kernel();
$k->provider(
    fn(Kernel $k) => null,
    function (Kernel $k): void {
        Ledger::ran('loadSettings');                 // in the boot phase, before any request starts
        $k->watcher('timer', fn() => null);          // the part to be called in every request
    },
);
$k->action('show', fn() => new Response(200, 'ok'));
$k->boot();
printf("after boot  loadSettings %s\n", classify('loadSettings'));
foreach ([1, 2] as $n) {
    $k->handle();
    printf("request %d  loadSettings %-11s timer %s\n", $n, classify('loadSettings'), classify('timer'));
}
```

```
after boot  loadSettings ran
request 1  loadSettings not wired   timer ran
request 2  loadSettings not wired   timer ran
```

**FF8.** The expected result is that the two parts fall under different outcomes, and the measurement confirmed it. Right after boot, `loadSettings` appears as **ran**, because `Ledger` is still holding the boot record. The first `handle()` call empties the lists with `newRequest()`, and `loadSettings` is **not wired** in both requests: the part really did run, but outside the request's window. `timer`, on the other hand, **ran** in both requests. What is set up in boot runs in every request; what runs in boot does not. Here not wired describes not a fault but the work being left outside the measurement window.

The practical counterpart of this distinction is as follows: adding a watcher in a provider's boot makes that watcher run in every request; but a preparation step that the provider performs during boot, such as reading a settings file, is done once and is not repeated in later requests. If a developer expects a part to run "in every request", the part should be registered on the request's path like a watcher, not in boot. Work placed in boot runs once, and someone who expects it in every request will not find it in the second.

The limit of this result is the model itself. The model calls boot once; in an arrangement that loads the script from scratch on every request, boot would fall inside every request and `loadSettings` would run in every request. Which arrangement is used is determined not by the framework but by the process in which the application is run.

#### Summary

- Boot runs in two phases: first the registration of all service providers, then all their boots; so a provider can rely in its boot on what the others offer.
- All four outcomes are at run time; deferred is not a stage but the work being left outside the response.
- Work left with `afterResponse()` leaves no trace in the response body and runs after the `finish()` call; if the same work is called directly in the action, it falls under ran.
- The `timer` set up in boot ran in both requests; `loadSettings`, which ran in boot, appeared as not wired in both requests.
- This lesson's outcome is **deferred**: the work runs after the response, and its failure does not show in the response.

#### Next Step

In this lesson the action the request lands on was given ready-made: a single action was registered and every request went to it. The next lesson, Routing, asks about the case where more than one registration fits the same request: if two patterns fit the same path, whose action is called, and is the other part never attached to the request at all?

### Routing

Source: https://academia.sh/en/courses/web-application-framework/routing

Route patterns, parameters, groups and named routes; the first matching route in registration order wins, and a route can be left unwired in the shadow of another route.

The previous lesson gave ready-made the action the request lands on. This lesson opens that step. To serve a request, the framework looks at the registered routes one by one, and the rule of that look consists of a single sentence. What happens when two patterns fit the same path depends on how that sentence is written.

#### The Route Pattern and the Parameter

A route brings four things together: an HTTP method, a path pattern, a part name and an action. The pattern carries the fixed parts of the path and the **parameters** written in braces. The pattern `/posts/{id}` fits the path `/posts/7`, and the action receives the `id` parameter as `7`. The framework turns the pattern into a regular expression: each parameter becomes a group that matches one or more characters that do not contain a slash.

So that the same pattern need not be repeated in many routes, routes are **grouped**. A group carries a common prefix: the pattern `/{id}` inside the `/posts` group actually becomes `/posts/{id}`. In real frameworks a group can also add a list of middleware to each of its members; middleware itself is the subject of the fifth lesson, and here only the prefix is modeled. The third tool is the **named route**: the route is registered under a name, and the URL is generated from the pattern by that name. This way, when a pattern changes, there is no need to search for every place the address was written.

`router.php` gathers these three tools and the matching rule in a single class. This file is given in full, and the next two measurements load it with `require_once`.

```php
<?php
// router.php — a small router that matches in registration order
declare(strict_types=1);
require_once 'ran.php';

final class Response {
    public function __construct(public int $status, public string $body = '') {}
}

final class Router {
    private array $routes = [];
    private array $names = [];
    private array $prefixes = [];
    public function group(string $prefix, Closure $register): void {
        $this->prefixes[] = $prefix;
        $register($this);
        array_pop($this->prefixes);
    }
    public function route(string $method, string $pattern, string $part, Closure $action, ?string $name = null): void {
        $pattern = implode('', $this->prefixes) . $pattern;
        $this->routes[] = [$method, $pattern, $part, $action];
        if ($name !== null) $this->names[$name] = $pattern;
    }
    public function url(string $name, array $params = []): string {
        return preg_replace_callback('#\{(\w+)(?::[^}]+)?\}#',
            fn(array $m) => (string) $params[$m[1]], $this->names[$name]);
    }
    public function handle(string $method, string $path): Response {
        Ledger::newRequest();
        foreach ($this->routes as [$m, $pattern, $part, $action]) {     // the first match wins
            $regex = '#^' . preg_replace_callback('#\{(\w+)(?::([^}]+))?\}#',
                fn(array $x) => '(?P<' . $x[1] . '>' . ($x[2] ?? '[^/]+') . ')', $pattern) . '$#';
            if ($m === $method && preg_match($regex, $path, $found)) {
                Ledger::onPath($part);
                Ledger::ran($part);
                return $action(array_filter($found, 'is_string', ARRAY_FILTER_USE_KEY));
            }
        }
        return new Response(404);
    }
}
```

The `ran.php` core is given again in every lesson with the same twenty-two lines. The router attaches to it with two calls: it reports the name of the matching route with both `Ledger::onPath()` and `Ledger::ran()`.

```php
<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}
```

**FF9.** The `foreach` in `handle()` tries the routes in the order they were registered and returns on the first match. This line is the rule of the whole lesson: **the first match wins.** The model does not judge which pattern is more specific by any ranking, and there is no rule such as "a fixed pattern comes before a parameterized pattern". The order is the order in which the developer wrote the `route()` calls. Some real frameworks soften this rule with their own additional ordering; the model measured here does not soften it, and this is a deliberate simplification.

#### Groups and Named Routes

`group()` works with a list of prefixes: a prefix is added, the `route()` calls inside the group run, and the prefix is taken back. Because the prefix is added to the front of the pattern inside `route()`, the matcher knows nothing of the group; it looks only at the full pattern. Groups can be nested, and each level adds its own prefix. This design has a consequence: a group is only a writing convenience and adds no new rule to matching. When `/{id}` and `/new` are registered inside the `/posts` group, these two patterns behave exactly as `/posts/{id}` and `/posts/new` written independently of the group would.

`url()` does the reverse. It takes the registered pattern, puts the given value in place of each `{parameter}` and returns the result. The route name is tied to the pattern, and the pattern is stored together with the prefix; so changing the group's prefix changes the address generated by every piece of code that uses the name. That is all the named route does in this lesson: it ties address generation to a single place in the pattern. It carries no word about which route will fit a request.

#### The First Match Wins

`routing.php` builds a `/posts` group and puts two routes inside it: `show` with the pattern `/{id}` and `form` with the pattern `/new`. `build()` produces two registration orders. In the first order, `show` is registered first and `form` after. In the second, it is the reverse. The words `after` and `before` in the output say whether `form` was registered after or before `show`. Both routers are asked about the same path, the address `/posts/new`; the part the developer expects is `form`. The `show` route inside the group also carries the name `posts.show`, and the last line generates a URL from that name with the value `7` for `id`.

```php
<?php
// routing.php — the registration order decides which route is wired
declare(strict_types=1);
require_once 'router.php';

function build(bool $newFirst): Router {
    $r = new Router();
    $r->group('/posts', function (Router $r) use ($newFirst) {
        $form = fn() => new Response(200, 'empty form');
        $show = fn(array $p) => new Response(200, "post {$p['id']}");
        if ($newFirst) $r->route('GET', '/new', 'form', $form);
        $r->route('GET', '/{id}', 'show', $show, name: 'posts.show');
        if (!$newFirst) $r->route('GET', '/new', 'form', $form);
    });
    return $r;
}

foreach (['after' => false, 'before' => true] as $label => $newFirst) {
    $r = build($newFirst);
    $response = $r->handle('GET', '/posts/new');
    printf("new %-6s %d %-10s form %s\n", $label, $response->status, $response->body, classify('form'));
}
echo $r->url('posts.show', ['id' => 7]), "\n";
```

```
new after  200 post new   form not wired
new before 200 empty form form ran
/posts/7
```

**FF10.** The same path, the same two routes, the only difference the registration order. When `show` was registered first, `/posts/new` fitted it: the `{id}` parameter took the value `new`, the response was 200 and its body came out as `post new`. The `form` action was **never called**; `Ledger` did not put it on the request's path, and when the decision order could not find it in the `$ran`, `$deferred` or `$path` lists either, it said **not wired**. When `form` was registered first, the same path fitted it and the `empty form` response came: `form` **ran**. The response is 200 in both cases and no error or warning appears; only the body and `Ledger` tell the two cases apart.

That is why the problem is insidious. The shadowed route is registered, and someone reading the route table sees it. The request never reaches it, and the framework does not report this; the `post new` response does not look like an error either, because `new` can be read as a valid `id` value. The developer realizes that a part did not run not from somewhere in the response but by **naming the part they expected and querying it**.

The generated URL is also part of the model: `/posts/7` on the last line was derived from the pattern. Code that uses the name instead of writing the address by hand does not break when the pattern changes. But the named route only generates URLs; what decides which route fits a request is still the registration order.

This lesson's outcome is **not wired**. This outcome, the last in the decision order, was named the most dangerous outcome in the first lesson: a skipped part shows through a status code, a deferred part shows in a worker or after the response, while a part that is not wired shows nowhere.

#### Reading the Route Table

The only reliable way to find a shadowed route is to write down the route's name beforehand and ask the request about it. Someone who reads the route table by eye sees the `form` route registered and thinks the work is done. `Ledger` reads not the table but the request: was `form` on the path in this request, and was it called. The two questions are separate, and only the second is what the developer really wonders about.

This habit of reading has two practical consequences. The first is that adding a new route can shadow an existing route: a broad pattern added later covers all the narrow patterns registered after it. This effect, which the person adding it does not see, is caught by measuring the same request before and after. The second is that a developer who realizes a route did not run tends to look first inside the action; yet the action was never called and there is nothing in it to search for. The right first question is which route the request fell on.

#### The Bounding Measurement

Does the order cause the shadow? The previous measurement showed that the result changes when the order changes; but that does not prove that the order is the only factor. The bounding measurement does **not** change the order. `show` is again registered first, only its pattern is narrowed to `{id:\d+}`: the parameter matches digits only. The same two paths are asked: `/posts/new` and `/posts/7`.

```php
<?php
// constraint.php — the breadth of the pattern causes the shadow, not the order
declare(strict_types=1);
require_once 'router.php';

$r = new Router();
$r->group('/posts', function (Router $r) {
    $r->route('GET', '/{id:\d+}', 'show', fn(array $p) => new Response(200, "post {$p['id']}"));
    $r->route('GET', '/new', 'form', fn() => new Response(200, 'empty form'));
});

foreach (['/posts/new' => 'form', '/posts/7' => 'show'] as $path => $part) {
    $response = $r->handle('GET', $path);
    printf("%-14s %d %-10s %s %s\n", $path, $response->status, $response->body, $part, classify($part));
}
```

```
/posts/new     200 empty form form ran
/posts/7       200 post 7     show ran
```

**FF11.** The order stayed the same, and this time `form` **ran**. Because the `show` pattern no longer matches the text `new`, the turn came to the `form` route; `/posts/7` fell on `show` again. What causes the shadow is not the order but the **broad pattern**: because `[^/]+` accepts any text, the word `new` fits it too. The order matters only when two patterns cover each other. If the pattern is narrowed, the order loses its importance.

What the two measurements say together is this: for a route you see is not wired, one of two ways fixes it, and both give the same result. Register the narrow pattern before the broad pattern, or narrow the broad pattern. The second is more robust, because the result does not change even if the order changes. The first depends on the developer's attention and can break again when the next route is added.

There is one more limit: by default this model matches a single path segment that contains no slash; it supports neither wildcards nor optional parameters. The matchers of real frameworks support these and more, such as domain constraints; what is measured here is only the consequence of the first match wins rule.

#### Summary

- A route consists of a method, a pattern, a part name and an action; a group adds a common prefix, and a named route generates a URL from the pattern.
- In the model the first match wins and there is no specificity ranking; the order is the order in which the routes were registered.
- When `/posts/{id}` was registered first, `/posts/new` fitted it, the response was 200 with `post new`, and `form` was not wired; when the order changed, `form` ran.
- Narrowing the parameter with `{id:\d+}` brought the `form` route to ran without changing the order; the shadow is caused not by the order but by the breadth of the pattern.
- This lesson's outcome is **not wired**: a shadowed route raises no error, the response looks successful, and the route is found only by naming it and querying it.

#### Next Step

The matching route is now known: `show` or `form` ran. But a route only points to an action; in which class the action lives, who builds that class's dependencies, and how a resource with seven actions spreads across routes have not yet appeared. The next lesson, Controllers, asks how the class the route points to is built and whether the action was really called.

### Controllers

Source: https://academia.sh/en/courses/web-application-framework/controllers

The controller class and the action method, the resource controller exposing seven actions as routes, and the single-action controller; the resolution of constructor dependencies by the object container.

The previous lesson dropped the request onto a route, but it assumed that what the route points to is a function. In real applications the action is a method of a class, and the class depends on other objects. This lesson opens the path from the route to the action: who builds the class, who supplies its dependency, and only then is the action called.

#### The Controller Class and the Action

A controller gathers related actions in one class. An action is a method of that class and serves a request: it takes the parameters, does some work and returns a response. The route now carries not a function but a class name and a method name. After the framework has matched the request to this route, it does three things: it builds the class, calls the method and gives back the response the method returned.

Building the class is not an easy step, because a controller may depend on other objects. A post controller needs a repository that stores posts, and it asks for it as a parameter in its constructor. The route cannot be expected to supply this repository; the party that builds is the framework. The framework leaves this to the **object container**: the factory that builds an object from a class name, whose details open in later lessons. The only thing to know here is that the container looks at the types of the constructor parameters and builds those as well. What was measured in the Dependency Injection lesson was the structure of this resolution; here only the building of the controller and the calling of the action matter.

`controller.php` sets up this chain in one file. `Router` registers a class and a method name for the route, and `handle()` takes the matching route's controller from the container and calls the action. The container resolves only class-typed constructor parameters; a parameter without a class type is outside the scope of this model. The file is given in full, and the measurements load it.

```php
<?php
// controller.php — binds a route to a controller class and an action method
declare(strict_types=1);
require_once 'ran.php';

final class Response {
    public function __construct(public int $status, public string $body = '') {}
}

final class ObjectContainer {
    public function get(string $name): object {
        $dependencies = [];
        foreach ((new ReflectionClass($name))->getConstructor()?->getParameters() ?? [] as $p) {
            $dependencies[] = $this->get($p->getType()->getName());
        }
        return new $name(...$dependencies);
    }
}

final class Router {
    private array $routes = [];
    public function __construct(private ObjectContainer $container) {}
    public function routes(): array { return $this->routes; }
    public function route(string $method, string $pattern, string $part, string $class, string $action = '__invoke'): void {
        $this->routes[] = [$method, $pattern, $part, $class, $action];
    }
    public function resource(string $name, string $class): void {
        $seven = [
            ['GET', "/$name", 'index'], ['GET', "/$name/create", 'create'], ['POST', "/$name", 'save'],
            ['GET', "/$name/{id}", 'show'], ['GET', "/$name/{id}/edit", 'edit'],
            ['PUT', "/$name/{id}", 'update'], ['DELETE', "/$name/{id}", 'delete'],
        ];
        foreach ($seven as [$method, $pattern, $action]) {
            if (method_exists($class, $action)) $this->route($method, $pattern, $action, $class, $action);
        }
    }
    public function handle(string $method, string $path): Response {
        Ledger::newRequest();
        foreach ($this->routes as [$m, $pattern, $part, $class, $action]) {      // the first match wins
            $regex = '#^' . preg_replace('#\{(\w+)\}#', '(?P<$1>[^/]+)', $pattern) . '$#';
            if ($m === $method && preg_match($regex, $path, $found)) {
                Ledger::onPath($part);
                $controller = $this->container->get($class);       // constructor dependencies are resolved
                Ledger::ran($part);
                return $controller->$action(array_filter($found, 'is_string', ARRAY_FILTER_USE_KEY));
            }
        }
        return new Response(404);
    }
}
```

The `ran.php` core is given again in every lesson.

```php
<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}
```

**FF12.** `handle()` makes two separate reports, and the controller is built between them. `Ledger::onPath()` is called when the route matches; `Ledger::ran()` is called after the `$this->container->get($class)` line, that is, after the controller has been built. If the build threw an exception, the part would have been put on the path but would not have entered the ran list; by the rule of `classify()`, that part would count as **skipped**. This order is deliberate: ran says the point where the action is about to be called has been reached; every step before the call is on the path but has not yet run.

#### The Resource Controller and the Single-Action Controller

In most applications an entity revolves around the same seven actions: list, show the create form, save, show, show the edit form, update and delete. Writing a route for each is repetition. The **resource controller** exposes these seven actions as routes with a single registration. `resource()` carries a seven-row table and registers a route for each action that is defined.

| Method | Pattern | Action |
|---|---|---|
| GET | /posts | index |
| GET | /posts/create | create |
| POST | /posts | save |
| GET | /posts/{id} | show |
| GET | /posts/{id}/edit | edit |
| PUT | /posts/{id} | update |
| DELETE | /posts/{id} | delete |

The order in the table is not arbitrary. The pattern `/posts/create` comes before the pattern `/posts/{id}`, because, as measured in the previous lesson, the first match wins: if the order were reversed, the word `create` would be read as an `id`. The resource controller registers the seven routes in this order and puts the narrow pattern before the broad pattern.

Every action is a method, but some actions deserve a class of their own. A **single-action controller** carries only the `__invoke` method, and the route gives only the class name. It suits an operation, such as publishing, that does not fall among an entity's standard seven actions. The file below registers both a resource controller and a single-action controller, prints the seven routes and one extra route, and then classifies two requests.

```php
<?php
// controllers.php — the resource controller exposes seven actions as seven routes
declare(strict_types=1);
require_once 'controller.php';

final class Repository {}

final class PostController {
    public function __construct(private Repository $repository) {}
    public function index(): Response { return new Response(200, 'list'); }
    public function create(): Response { return new Response(200, 'form'); }
    public function save(): Response { return new Response(201, 'saved'); }
    public function show(array $p): Response { return new Response(200, 'post ' . $p['id'] . ' (' . $this->repository::class . ')'); }
    public function edit(array $p): Response { return new Response(200, "edit {$p['id']}"); }
    public function update(array $p): Response { return new Response(200, "updated {$p['id']}"); }
    public function delete(array $p): Response { return new Response(204); }
}

final class PublishController {
    public function __invoke(array $p): Response { return new Response(200, "published {$p['id']}"); }
}

$r = new Router(new ObjectContainer());
$r->resource('posts', PostController::class);
$r->route('POST', '/posts/{id}/publish', 'publish', PublishController::class);

foreach ($r->routes() as [$method, $pattern, $part]) {
    printf("%-6s %-22s %s\n", $method, $pattern, $part);
}
foreach ([['GET', '/posts/7', 'show'], ['POST', '/posts/7/publish', 'publish']] as [$method, $path, $part]) {
    $response = $r->handle($method, $path);
    printf("%-4s %-19s %d %-20s %s %s\n", $method, $path, $response->status, $response->body, $part, classify($part));
}
```

```
GET    /posts                 index
GET    /posts/create          create
POST   /posts                 save
GET    /posts/{id}            show
GET    /posts/{id}/edit       edit
PUT    /posts/{id}            update
DELETE /posts/{id}            delete
POST   /posts/{id}/publish    publish
GET  /posts/7            200 post 7 (Repository)  show ran
POST /posts/7/publish    200 published 7          publish ran
```

**FF13.** Seven actions were exposed as seven routes, and the eighth route belongs to the single-action controller. In the `/posts/7` request `PostController` was built, and the `Repository` parameter in its constructor was resolved by the container with `ReflectionClass`; the name `Repository` in the response body is the proof. `new Repository()` was written nowhere. The action **ran**. The single-action controller was built in the same way and `__invoke` was called; `publish` ran too.

This lesson's outcome is **ran**. But the ran outcome says something new: for an action to run, the route matching is not enough; the controller must also be buildable. The action of a controller that cannot be built is not called; if we read the rule of `classify()`, that part stays on the path and falls under skipped. The measurements of this lesson do not run this case; they rest only on the rule.

#### The Work of a Controller

A controller stands at the boundary of the request. It takes the request's parameters, has other objects do the work and builds the response. Writing the work itself, that is, how a post is stored or by which rule it is validated, inside the controller ties that code to the request: it cannot be called from a command-line script or from a worker. This is why the action is kept small and the real work sits in an object such as the `Repository` asked for in the constructor. This is also the reason the controller asks for dependencies in its constructor: the action does not build the object that does the work; it waits for it to be given.

In this measurement the responses are deliberately kept short: the `delete` action returns only a response with status 204, and `save` gives a message with 201. Everything an action returns is a `Response` object; the action does not write output itself. The framework takes the response and takes charge of sending it. If an action wrote output directly instead of a response, it would make it impossible for work that runs after the response, and for middleware on the return path, to touch the response.

The choice between the two controller forms is a matter of style. For an entity that revolves around the standard seven actions, the resource controller registers seven routes with one class and fixes the addresses by a rule. The single-action controller is used for an operation that fits none of the seven actions, and in the route table the name of the class is the name of the operation. Choosing one does not change matching; in both, the first match wins, and in both, the action running depends on the controller being built.

#### The Bounding Measurement

The resource controller does not register a route for actions that are not defined: the `method_exists` check in `resource()` filters the seven actions. `DraftController` carries only `index` and `show`. In this case, what happens to the request `GET /drafts/7/edit`? Is the part bound to another step on the path, or does the request give 404? The second request is `GET /drafts/create`, that is, the fixed address of an undefined action.

```php
<?php
// missing.php — the route of an undefined action is never registered
declare(strict_types=1);
require_once 'controller.php';

final class DraftController {
    public function index(): Response { return new Response(200, 'drafts'); }
    public function show(array $p): Response { return new Response(200, "draft {$p['id']}"); }
}

$r = new Router(new ObjectContainer());
$r->resource('drafts', DraftController::class);

foreach ([['/drafts/7/edit', 'edit'], ['/drafts/create', 'create']] as [$path, $part]) {
    $response = $r->handle('GET', $path);
    printf("%-21s %d %-16s %s %s\n", $path, $response->status, $response->body, $part, classify($part));
}
```

```
/drafts/7/edit        404                  edit not wired
/drafts/create        200 draft create     create not wired
```

**FF14.** The result came out in two separate forms. Because the route for `edit` was never registered, no pattern fitted the request: the response was **404** and `edit` was **not wired**. Although this is a not wired, it is not silent, because the 404 status code says the request fell on no route. For `create`, the table did not register the fixed address, but the pattern `/drafts/{id}` accepted the word `create` as an `id`: the response was **200** and the body `draft create`, and the part was again **not wired**. The same outcome appeared this time silently, not through a status code. In both cases the part the developer expected was queried by the same naming and fell under the same outcome; what told them apart was the status of the response.

This result joins the previous lesson: a route never being registered and a route being shadowed fall under the same not wired outcome, but one arrives with 404 and the other with 200. The developer may not notice the action the resource controller left out; the framework does not give this as a warning, because the `method_exists` filter is deliberately silent.

#### Summary

- A route carries a class name and a method name; after matching, the framework takes the controller from the object container and has the constructor dependencies resolved with `ReflectionClass`.
- The resource controller exposes seven actions as seven routes and registers the narrow pattern before the broad pattern; the single-action controller carries only `__invoke`.
- In the `/posts/7` request the controller was built, the `Repository` dependency was resolved and the action ran; publish ran in the same way.
- Because the route of the undefined `edit` action was not registered, the request gave 404 and the part was not wired; the undefined `create` action fell on the `/drafts/{id}` pattern, the response was 200 and the part was again not wired.
- This lesson's outcome is **ran**: for an action to run, the route must match and the controller must be built.

#### Next Step

The route built the controller and called the action. But there are also steps that every request must pass before the action: whether the session is open, whether the input is valid. If these checks are written inside the action, they are repeated in every controller. The next lesson, Middleware, puts these checks in front of the action and asks a single question: if the layer in front gives the response, what happens to the action and the layers behind it?

### Middleware

Source: https://academia.sh/en/courses/web-application-framework/middleware

Moving cross-cutting concerns into layers placed in front of the action, the request passing through the layers to reach the action, the call to $next being made or not made, and the order of the links deciding the result.

The previous lesson showed that for an action to run, the route must match and the controller must be built. But there may also be steps in front of the action that every request must pass. If these checks are written inside the action, they are repeated in every controller. The framework puts them in one place in front of the action, and before the action is called, a response may be produced at that place.

#### Cross-Cutting Concerns and Middleware

Jobs such as whether the session is open, whether the input is valid, or whether a header is added to the response concern many actions in the same way. These are called **cross-cutting concerns**: none of them belongs to one action, but all of them lie on the path of every action. **Middleware** is a function that does this work in one place. It takes the request and a `$next` function and does one of two things: either it passes the request on by calling `$next`, which continues the chain, or it returns its own response without calling `$next`.

The layers form a **chain**. The action is the innermost link of the chain; each layer is an outer link and receives the one inside it as `$next`. The request enters the outermost link, descends one step inward as each layer calls `$next`, and reaches the action. The response returns along the same path in reverse: the response of the action reaches the innermost layer, from there the layer outside it, and finally the caller. This is why a layer can have two parts: the part that runs before the call to `$next` and the part that runs after the call, on the way back.

`middleware.php` builds this structure. `chain()` wraps the layers in reverse order: the action stays innermost and the layer wrapped last stays outermost. There are three ready-made layers. `sessionLayer()` gives the response itself, a 302 redirecting to `/login`, if there is no session. `validationLayer()` gives 422 if the title is empty. `traceLayer()` calls `$next` and adds a header to the response **on the way back**.

One consequence of this structure is that middleware is not a filter concerned only with the request. The call to `$next` has two sides, and each side suits a different job. The part before the call examines or changes the request: it reads the session, validates the input, adds a field to the request. The part after the call examines or changes the response: it adds a header, wraps the body, measures the time. A single layer can do both, and both stand inside the same function, on the two sides of the same `$next` call.

The layer has a third option, and this lesson deliberately does not measure it: calling `$next` more than once, or never returning. Frameworks usually leave this to the user. The rule is simple: a layer either continues the chain or gives the response itself, and which one it does is decisive for the rest of the chain.

```php
<?php
// middleware.php — nested middleware layers and the chain
declare(strict_types=1);
require_once 'ran.php';

final class Request {
    public function __construct(
        public string $path, public array $body = [], public bool $session = false,
    ) {}
}

final class Response {
    public array $headers = [];
    public function __construct(public int $status, public string $body = '') {}
}

function sessionLayer(): array {
    return ['session', fn(Request $r, Closure $next) =>
        $r->session ? $next($r) : new Response(302, '/login')];
}

function validationLayer(): array {
    return ['validation', fn(Request $r, Closure $next) =>
        ($r->body['title'] ?? '') !== '' ? $next($r) : new Response(422, 'title required')];
}

function traceLayer(): array {                 // also runs after $next returns
    return ['trace', function (Request $r, Closure $next): Response {
        $response = $next($r);
        $response->headers['x-trace'] = 'passed';
        return $response;
    }];
}

function chain(Request $request, array $layers, string $part, Closure $action): Response {
    Ledger::newRequest();
    foreach ([...array_column($layers, 0), $part] as $p) Ledger::onPath($p);
    $link = function (Request $r) use ($part, $action): Response {
        Ledger::ran($part);
        return $action($r);
    };
    foreach (array_reverse($layers) as [$name, $layer]) {      // the outermost layer is wrapped last
        $link = function (Request $r) use ($name, $layer, $link): Response {
            Ledger::ran($name);
            return $layer($r, $link);
        };
    }
    return $link($request);
}
```

The `ran.php` core is given again in every lesson.

```php
<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}
```

**FF15.** `chain()` does two jobs, and they are separate. The first `foreach` puts all the layer names and the part on the **path**: every link in the chain for this request is on the path. Then, as each link is called, it reports itself as ran with `Ledger::ran()`. The difference is deliberate: the path says what the chain is; ran says how far the chain advanced. If a link is on the path but not in the ran list, a link before it did not call `$next`, and the decision order makes that part **skipped**.

#### If the Layer in Front Gives the Response

The chain is in the order `[traceLayer, sessionLayer, validationLayer]` and the action is `save`. `trace` is outermost. Two requests are processed: one without a session and one with a session. The body of both is valid, that is, it carries a title that will pass validation; the only difference is the session.

```php
<?php
// layers.php — if the layer in front gives the response, the layers behind are skipped
declare(strict_types=1);
require_once 'middleware.php';

$layers = [traceLayer(), sessionLayer(), validationLayer()];
$action = fn(Request $r) => new Response(201, 'saved');
$body = ['title' => 'Hello'];

foreach (['unauthenticated' => false, 'authenticated' => true] as $name => $session) {
    $response = chain(new Request('/posts', $body, $session), $layers, 'save', $action);
    printf("%s -> %d %s %s\n", $name, $response->status, $response->body, json_encode($response->headers));
    foreach (['trace', 'session', 'validation', 'save'] as $part) {
        printf("  %-10s %s\n", $part, classify($part));
    }
}
```

```
unauthenticated -> 302 /login {"x-trace":"passed"}
  trace      ran
  session    ran
  validation skipped
  save       skipped
authenticated -> 201 saved {"x-trace":"passed"}
  trace      ran
  session    ran
  validation ran
  save       ran
```

**FF16.** In the request without a session, the `session` layer did not call `$next` and returned 302. The `validation` layer and the `save` action behind it were on the path but were not called; both are **skipped**. In the request with a session, `session` called `$next` and every link of the chain was called: all four parts **ran** and the response was 201. The same chain, the same action; the only difference is the `session` field of the request.

There is one more detail in the first request: although the response is 302, the header carries `x-trace`. The `trace` layer is outside the session layer; it called `$next`, received the response as 302 and added the header on the way back. The part that runs on the way back runs for the outer layers even when an inner layer gives an early response. That is, the part of a layer before `$next` can be skipped, but the return part of an outer layer is not skipped.

This measurement has a limit. `Ledger` says whether a link was called; it does not say what the called link did inside. The `trace` layer appears as ran, but the fact that it added the header on the way back is not in the ledger; we read it only from the headers of the response. The ran outcome therefore means "the link was called", not "all the work of the link finished". Even if the part that runs on the way back had been cut by an error, the link would have looked as ran.

This difference matters in middleware errors. If the part of a layer before the call fails, the chain is cut there, and the links behind are on the path but were not called: in the ledger they appear as skipped. The cause of a skip may be a normal early response or an error; what tells them apart is the status code and the response body, not the outcome. For this reason the skipped outcome is not a sign of failure; it is a sign of control flow.

This lesson's outcome is **skipped**. A skipped part gives itself away by a status code: 302 says the session stopped the request, 422 says the validation did. But the status code shows only the layer that gave the response; it does not show how many parts behind were skipped, and `Ledger` lists that.

#### The Bounding Measurement

Does the order of the links change the result? The order of two layers decides which one gives the response. A request without a session **and** with an empty title meets the response condition of both layers. The same request is measured in two separate orders: first `session`, then `validation`; then the reverse.

```php
<?php
// order.php — the order of the links decides which layer gives the response
declare(strict_types=1);
require_once 'middleware.php';

$action = fn(Request $r) => new Response(201, 'saved');
$request = new Request('/posts', ['title' => ''], false);     // no session and an empty title

foreach (['session first' => [sessionLayer(), validationLayer()],
          'validation first' => [validationLayer(), sessionLayer()]] as $name => $layers) {
    $response = chain($request, $layers, 'save', $action);
    printf("%s: %d %s\n", $name, $response->status, $response->body);
    foreach (['session', 'validation', 'save'] as $part) {
        printf("  %-10s %s\n", $part, classify($part));
    }
}
```

```
session first: 302 /login
  session    ran
  validation skipped
  save       skipped
validation first: 422 title required
  session    skipped
  validation ran
  save       skipped
```

**FF17.** The two orders gave two different results. When `session` came first, the request received 302, `session` ran, and `validation` and `save` were skipped. When `validation` came first, the request received 422, `validation` ran, and `session` and `save` were skipped. The request and the layers are the same; only the order changed, and the status code and the skipped part changed together. The action was skipped in both orders, because in both the layer in front gave the response itself.

Which layer is skipped matters here. In the order with `validation` first, a user without a session learns the details of the input: the 422 response says the title is empty. In the order with `session` first, the request without a session is redirected without the input being looked at at all. Which order is right is a security and usability decision; the measurement does not settle it, it only shows that the order changes the result.

In practice the order is also chosen by which layer is expensive. The session check is usually cheap and rejects the request at the door when needed; validation may require parsing the body. If a cheap layer that rejects many requests is put in front, the layers behind it that are skipped never pay this cost. In this measurement both layers are cheap, and only which of them gave the response was measured; cost is the subject of a separate measurement.

#### Summary

- Middleware takes the request and the `$next` function and either continues the chain by calling `$next`, or returns its own response without calling it.
- In the chain the action is innermost; the request moves from outside to inside, the response from inside to outside, and the part of a layer after `$next` runs on the way back.
- In the request without a session, `session` returned 302, and `validation` and `save` were skipped; the outer `trace` layer still added a header to the response. In the request with a session, all four parts ran.
- When `session` came first, the same request received 302; when `validation` came first, it received 422; the order of the links decides which layer gives the response.
- This lesson's outcome is **skipped**: a skipped part is on the path, but a layer in front gave the response early.

#### Next Step

In this lesson we built the layers and the action by hand inside `chain()`: `chain()` did not know which object a part depended on. In a real framework the layers, the controllers and their dependencies sit in an object container and are taken by name. The next lesson, Object Container and Facades, asks what happens if the container binds a name twice: is the first binding or the second one resolved, and does the overwritten part count as bound to the request?

### Object Container and Facades

Source: https://academia.sh/en/courses/web-application-framework/object-container-and-facades

Binding in the object container, singleton binding and autowiring, a second binding to the same name silently overwriting the first, and the facade resolving the object from the container on every call.

The previous lesson showed that when one of the layers in front of the action gives a response, the rest of the chain is skipped. The layers, the controllers and their dependencies are built somewhere outside the chain. In a framework this place is the **object container**: a registry that builds the object requested by name and, if the object needs a dependency, builds that too and hands it over. Which binding the container chooses when a name is bound twice is what this lesson measures.

#### The Object Container Builds Objects in Three Ways

The object container has appeared twice before in this course; here we open all three of its ways. The first way is **binding**: `bind()` associates a name with a recipe, that is, a function that builds the object. The second way is **singleton binding**: the recipe runs once at the first resolution and the resulting object is stored; all later `get()` calls return the same object. The third way is **autowiring**: if the name is not bound, the container looks at the types of the constructor parameters of the class with `ReflectionClass` and takes each of them from the container in turn.

Why a dependency is supplied from outside was explained in the Dependency Injection lesson of M12/K02: the class does not build its dependency itself, it receives it from outside. There the one who supplied the dependency by hand was us. Here it is the container, and the price of this is that who builds what is moved into a registry. In M08/K06, the other face of the framework course, this handed-over control is called inversion of control, and the question there is from which source a behavior comes. The question here is a different one: whether a bound part actually ran in this request.

`container.php` builds the container. The `Logger` interface has two implementations: `FileLogger` and `MemoryLogger`. When `write()` is called, both report their own name to the ledger as ran. `bind()` writes each binding into the `$recipes` array by name; because the array key is the name, if the same name exists the earlier binding is **silently overwritten**. `bindingsToPath()` puts the bound parts in the container on the path: the path is the set of bindings the container holds in this request. `Facade` and `Log` will be used later in the lesson: a facade is a way of reaching an object in the container through a static name.

The order of the three ways is also visible inside `get()`, and the order matters. First the stored singleton object is looked at, then the bound recipe, and last autowiring. So if a singleton object exists for a name, the binding is never asked; if a binding exists, the constructor of the class is never read. This is why autowiring is the default behavior: every class that is not bound works, as long as the types of its constructor parameters can be resolved. If a parameter is an interface or a scalar value, resolution stops, because the container cannot know which implementation that name should go to. Tying an interface to an implementation is the real job of binding; for this reason the `Logger` interface cannot be resolved without a binding.

```php
<?php
// container.php — binding, singleton binding, autowiring and the facade
declare(strict_types=1);
require_once 'ran.php';

interface Logger { public function write(string $message): void; }

final class FileLogger implements Logger {
    public function write(string $message): void { Ledger::ran('FileLogger'); }
}

final class MemoryLogger implements Logger {
    public function write(string $message): void { Ledger::ran('MemoryLogger'); }
}

final class ObjectContainer {
    private array $recipes = [];
    private array $singletons = [];
    public function bind(string $name, Closure $recipe, bool $singleton = false, string $part = ''): void {
        $this->recipes[$name] = [$recipe, $singleton, $part];      // if the name exists, it is silently overwritten
    }
    public function bindingsToPath(): void {
        foreach ($this->recipes as [, , $part]) Ledger::onPath($part);
    }
    public function get(string $name): object {
        if (isset($this->singletons[$name])) return $this->singletons[$name];
        if (isset($this->recipes[$name])) {
            [$recipe, $singleton] = $this->recipes[$name];
            $object = $recipe($this);
            if ($singleton) $this->singletons[$name] = $object;
            return $object;
        }
        $dependencies = [];
        foreach ((new ReflectionClass($name))->getConstructor()?->getParameters() ?? [] as $p) {
            $dependencies[] = $this->get($p->getType()->getName());
        }
        return new $name(...$dependencies);
    }
}

abstract class Facade {
    private static ObjectContainer $container;
    public static function setContainer(ObjectContainer $container): void { self::$container = $container; }
    abstract protected static function name(): string;
    public static function __callStatic(string $method, array $arguments): mixed {
        return self::$container->get(static::name())->$method(...$arguments);      // resolved from the container on every call
    }
}

final class Log extends Facade {
    protected static function name(): string { return Logger::class; }
}
```

The `ran.php` core is given again in every lesson.

```php
<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}
```

**FF18.** If there is a single binding under the name `Logger` in the container, `get()` runs the recipe of that binding, and the `write()` call of the returned object appears as ran. If a second binding is added under the same name, the first binding in the `$recipes` array is deleted; resolution runs the second binding. The implementation of the first binding never enters the path, because only the bindings that remain in the container are put on the path.

#### The Second Binding Overwrites the First

Two containers are built. In the first, `FileLogger` is bound under the name `Logger` first and `MemoryLogger` after it. In the second, only `FileLogger` is bound. In each container the same request is made: the bindings are put on the path, `Logger` is taken from the container and `write()` is called.

```php
<?php
// binding.php — a second binding to the same name silently overwrites the first
declare(strict_types=1);
require_once 'container.php';

function measure(ObjectContainer $container): void {
    Ledger::newRequest();
    $container->bindingsToPath();
    $container->get(Logger::class)->write('recorded');
}

$two = new ObjectContainer();
$two->bind(Logger::class, fn() => new FileLogger(), part: 'FileLogger');
$two->bind(Logger::class, fn() => new MemoryLogger(), part: 'MemoryLogger');

$one = new ObjectContainer();
$one->bind(Logger::class, fn() => new FileLogger(), part: 'FileLogger');

foreach (['two-bindings' => $two, 'one-binding' => $one] as $name => $container) {
    measure($container);
    foreach (['FileLogger', 'MemoryLogger'] as $part) {
        printf("%-13s %-13s %s\n", $name, $part, classify($part));
    }
}
```

```
two-bindings  FileLogger    not wired
two-bindings  MemoryLogger  ran
one-binding   FileLogger    ran
one-binding   MemoryLogger  not wired
```

**FF19.** In the container with two bindings, `MemoryLogger` **ran**, but `FileLogger` was **not wired**: the line that binds it still stands in the file, and no error or warning appeared. In the container with a single binding, the same `FileLogger` **ran**. The difference is not in the presence of that binding in the file but in whether it remained in the container. The developer who wrote the binding may assume that the first line is in force; but what is in force in the container is the binding written last.

Why this class is hard to catch comes from the nature of binding. Bindings usually sit in the startup files of the framework and the application, in places far from each other: one is the framework's own bindings, one is the application's, one is a package's. Which of two bindings that write to the same name will be last depends on the order in which the files are loaded. Each binding looks correct on its own; the conflict appears only when the two are loaded together. In the measurement we built this conflict with two consecutive lines; in a real application there may be hundreds of lines and several files between them.

The work the measurement draws from this is to ask for the expected implementation by name. Reading the binding is not enough, because the line that is read may not be the line in force. The list of parts on the path is the list of bindings that remain in the container; if the expected part is not in this list, the binding was either overwritten or never made, and the two look the same from outside.

This lesson's outcome is **not wired**. How it differs from skipped is this: a skipped part was on the path and was not called because a layer in front gave a response; a part that is not wired never entered the path, because its binding is not in the container. A thing that leaves no trace in the ledger announces itself neither with an error message nor with a status code. For this reason not wired is the most dangerous outcome in the decision order, and to see it, the developer must have named the expected part.

#### The Bounding Measurement

Does a second binding overwriting the first hold in every condition? This measurement combines two things: singleton binding and the facade. When `Log::write()` is written, the `Log` facade runs PHP's `__callStatic` magic method; this method resolves the name `Logger` from the container and passes the call to the object it obtains. The facade does not store the object; it takes it from the container again on every call.

The container is first bound to `FileLogger` as a **singleton**. After the first call, `MemoryLogger` is bound to the same name, and then the facade is called again.

```php
<?php
// facade.php — singleton binding hides rebinding
declare(strict_types=1);
require_once 'container.php';

$container = new ObjectContainer();
$container->bind(Logger::class, fn() => new FileLogger(), singleton: true, part: 'FileLogger');
Facade::setContainer($container);

foreach (['first call', 'after rebinding'] as $step => $name) {
    if ($step === 1) {
        $container->bind(Logger::class, fn() => new MemoryLogger(), part: 'MemoryLogger');
    }
    Ledger::newRequest();
    $container->bindingsToPath();
    Log::write('recorded');
    printf("%s: File %s, Memory %s\n", $name, classify('FileLogger'), classify('MemoryLogger'));
}
```

```
first call: File ran, Memory not wired
after rebinding: File ran, Memory skipped
```

**FF20.** In the first call `FileLogger` ran, and by the rule of singleton binding the object was stored. The second binding really overwrote the recipe in `$recipes`: `MemoryLogger` entered the path. But before looking at the recipe, `get()` looks at the stored singleton object, and because it found it, it returned the old object. As a result, in the second call `FileLogger` ran again, and the rebound `MemoryLogger` was **skipped** although it was on the path. The result in the previous section, that is, the second binding winning, holds if the singleton has not yet been resolved; once it has been resolved, the stored object wins.

This limit has one more consequence: rebinding in a test or in the middle of a request together with singleton binding may not show the expected effect. After rebinding, the stored object must also be made to forget; otherwise the new binding looks as if it is in force, but resolution still gives the old one. The container used in this lesson does not do this, and this is left so on purpose, because for the limit to be visible the container must have a storing point.

This measurement also shows that the facade does not create a problem on its own. The facade asks the container on every call; what goes to the wrong object is not the facade but the container's answer. The facade makes the problem harder to see because it hides this complexity behind a static name: the line `Log::write()` does not say which implementation will run.

#### Summary

- The object container builds objects in three ways: binding, singleton binding and autowiring; the facade is a way of reaching an object in the container through a static name.
- A second binding to the same name overwrites the first without an error or warning; in the container with two bindings `FileLogger` was not wired and `MemoryLogger` ran.
- In the container with a single binding the same `FileLogger` ran; the difference is not that the binding is in the file but that it remains in the container.
- After a singleton binding is resolved, rebinding does not change the stored object: `FileLogger` ran, and `MemoryLogger` was on the path but was skipped.
- This lesson's outcome is **not wired**: a binding that does not remain in the container does not enter the path and leaves no trace in the ledger.

#### Next Step

For six lessons we built the parts of the framework one by one, by hand: the lifecycle, the route, the controller, the middleware, the container. The next topic, Views and Data, moves to the body of the response these produce, and its first lesson, Template Engine, asks this question: which step does the template run again each time, and if the result of that step is stored, what does the next request see?

## Views and Data

The template engine, forms and validation, object–table binding, relationships, migrations and seeding, pagination and resource transformation — each as a mechanism that decides whether a record, a rule or a field takes part in this request.

### The Template Engine

Source: https://academia.sh/en/courses/web-application-framework/template-engine

A template compiler with layouts, components and loops is built; the lesson measures that escaping runs on every output, that compilation is skipped for an unchanged template, and that escaping is not wired on raw output.

When the object container bound two different things to the same name, which one came back depended on registration order. In the view layer a similar question changes direction: when a template produces output, which step that the developer did not write runs every time? The answer is a step that is assumed to be safe, and this lesson measures that assumption.

The measurement axis of this lesson is the same as in the rest of the course: did the expected part run in this request. Every measurement starts with `Ledger::newRequest()`, parts are recorded with `Ledger::onPath()`, `Ledger::ran()` and `Ledger::defer()`, and the result is read from the `classify()` function. The decision order is the function itself: first **ran**, then **deferred**, then **skipped**, and if none of these applies, **not wired**. The developer names the expected part; the function only says which outcome the named part falls into. This lesson's outcome is **ran**.

**VD1.** Templates are `.html` files under `templates/` in the working directory, and their compiled counterparts are written as `.php` under `cache/`; a real template engine builds a parser, and here regular expressions are enough. **VD2.** Escaping is done with `htmlspecialchars` and only harmless markup is measured; the template data is always an array in the developer's hands. **VD3.** File times have one-second resolution; to keep the measurement deterministic, the time is moved forward with `touch()` and `sleep` is not used.

The file below builds the `Ledger` class and the `classify()` function. It is the same measurement setup as in the previous lessons and is used here as it is.

```php
<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}
```

#### Compiling a Template into Code

A template engine turns a text file into PHP code, writes that code once, and runs it on every request. The file below contains a compiler with nine rules, a layout mechanism and a component call. `{{ name }}` becomes escaped output, the raw output syntax becomes unescaped output, `{% each ... %}` becomes a loop, `{% layout ... %}` and `{% section ... %}` become the layout, and `{% component ... %}` becomes a component. Escaping is done in the `escape()` function, and the function records itself when it is called; for every `{{ }}` the compiler generates code that calls this function, so the step is not left to the template author.

```php
<?php
// template.php — a small template compiler and engine
declare(strict_types=1);
require_once 'ran.php';

function escape(mixed $value): string {
    Ledger::ran('escaping');
    return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}

final class TemplateEngine {
    public array $sections = [];
    private ?string $layout = null;
    private array $open = [];

    public function __construct(private string $dir, private string $cache) {
        if (!is_dir($cache)) mkdir($cache);
    }

    private function compile(string $source): string {
        $rules = [
            '/\{\{!\s*(\w+)\s*\}\}/'              => '<?= $$1 ?>',
            '/\{\{\s*(\w+)\s*\}\}/'               => '<?= escape($$1) ?>',
            '/\{%\s*each (\w+) as (\w+)\s*%\}/'   => '<?php foreach ($$1 as $$2): ?>',
            '/\{%\s*\/each\s*%\}/'                => '<?php endforeach; ?>',
            '/\{%\s*section (\w+)\s*%\}/'         => '<?php $engine->openSection(\'$1\'); ?>',
            '/\{%\s*\/section\s*%\}/'             => '<?php $engine->closeSection(); ?>',
            '/\{%\s*layout (\w+)\s*%\}/'          => '<?php $engine->selectLayout(\'$1\'); ?>',
            '/\{%\s*yield (\w+)\s*%\}/'           => '<?= $engine->sections[\'$1\'] ?? \'\' ?>',
            '/\{%\s*component (\w+)\s*%\}/'       => '<?= $engine->component(\'$1\', get_defined_vars()) ?>',
        ];
        return preg_replace(array_keys($rules), array_values($rules), $source);
    }

    private function run(string $name, array $data): string {
        $source = "{$this->dir}/$name.html";
        $compiled = "{$this->cache}/$name.php";
        Ledger::onPath('compile');
        if (!is_file($compiled) || filemtime($compiled) < filemtime($source)) {
            Ledger::ran('compile');
            file_put_contents($compiled, $this->compile(file_get_contents($source)));
        }
        $data['engine'] = $this;
        return self::runFile($compiled, $data);
    }

    private static function runFile(string $__file, array $__data): string {
        extract($__data);
        ob_start();
        require $__file;
        return ob_get_clean();
    }

    public function show(string $name, array $data = []): string {
        $page = $this->run($name, $data);
        if ($this->layout === null) return $page;
        Ledger::ran('layout');
        $layout = $this->layout;
        $this->layout = null;
        return $this->run($layout, $data);
    }

    public function component(string $name, array $data): string {
        Ledger::ran('component');
        unset($data['__file'], $data['__data']);
        return $this->run($name, $data);
    }

    public function selectLayout(string $name): void { $this->layout = $name; }
    public function openSection(string $name): void { $this->open[] = $name; ob_start(); }
    public function closeSection(): void { $this->sections[array_pop($this->open)] = ob_get_clean(); }
}

function report(array $parts): void {
    foreach ($parts as $part) printf("  %-10s %s\n", $part, classify($part));
}
```

Compilation runs when the compiled file does not exist or is older than the source. `Ledger::onPath('compile')` is written on every call, `Ledger::ran('compile')` only when the file is written. When a part is placed on the path but never called, `classify()` classifies it as **skipped**; this lesson's opposing measurement rests on that difference.

Three small templates are needed: a main layout, a list page, and one component per row. Each time it runs, the file empties the cache so that the first request always starts from scratch.

```php
<?php
// templates.php — writes three templates and empties the compile cache
if (!is_dir('templates')) mkdir('templates');
if (!is_dir('cache')) mkdir('cache');
foreach (glob('cache/*') as $old) unlink($old);

file_put_contents('templates/main.html', <<<'HTML'
<title>{{ title }}</title>
<main>{% yield content %}</main>
HTML);

file_put_contents('templates/list.html', <<<'HTML'
{% layout main %}
{% section content %}
<ul>
{% each posts as post %}{% component card %}{% /each %}
</ul>
{% /section %}
HTML);

file_put_contents('templates/card.html', "<li>{{ post }}</li>\n");
```

#### Seeing Escaping Run

The measurement makes three requests. In the first request the three values in the list are printed with `{{ post }}`; one of the values contains `&`, one is `<b>bold</b>`, and one is a double quote. Expected: the escaping step runs, and the markup stays in the output as text. The second request runs the same template unchanged; compilation should be **skipped** because the compiled file is not older than the source, but escaping still runs on every request, because it sits inside the compiled code. Before the third request, the time of `card.html` is moved forward.

```php
<?php
// escaping.php — escaping runs on every {{ }} output; compilation is skipped on the second request
require_once 'template.php';
require_once 'templates.php';

$engine = new TemplateEngine('templates', 'cache');
$data = ['title' => 'Posts', 'posts' => ['Tom & Jerry', '<b>bold</b>', '"quote"']];

Ledger::newRequest();
echo $engine->show('list', $data), "\n";
report(['escaping', 'layout', 'component', 'compile']);

Ledger::newRequest();
$engine->show('list', $data);
echo "second request\n";
report(['escaping', 'compile']);

touch('templates/card.html', time() + 5);
Ledger::newRequest();
$engine->show('list', $data);
echo "card.html updated\n";
report(['compile']);
```

```
<title>Posts</title>
<main><ul>
<li>Tom &amp; Jerry</li>
<li>&lt;b&gt;bold&lt;/b&gt;</li>
<li>&quot;quote&quot;</li>
</ul>
</main>
  escaping   ran
  layout     ran
  component  ran
  compile    ran
second request
  escaping   ran
  compile    skipped
card.html updated
  compile    ran
```

In the first request all four parts are **ran**. The layout ran because the page template calls the main template after it has run; the component ran because it is called on every turn of the loop; escaping ran at every `{{ }}` point; and compilation ran because the cache was empty. In the second request the distinction is clear: compilation is **skipped**, escaping **ran**. Compilation is a cache decision, escaping is not; even when the decision changes, escaping keeps running because it is inside the compiled code. In the third request only the changed component's file is recompiled, and the main template stays the same. This measurement measures an outcome, not a count: how many times something ran was a question asked in an earlier course and is not asked here.

#### The Opposing Measurement: Skipped Compilation

The three requests above already contain the opposing measurement. The compile row of the second request is the case where a step is placed on the path but not called: `Ledger::onPath('compile')` was written, `Ledger::ran('compile')` was not. This means **skipped**. It is not an error; it is the purpose of the cache. The reverse case, where the expected part is never placed on the path at all, is the subject of the next section.

#### The Bounding Measurement

The claim that escaping runs on every output is bounded when the raw output syntax is used. The measurement below writes two templates: one with `{{ summary }}`, the other printing the same value with the raw output syntax. The value is the markup `<b>bold</b>`.

```php
<?php
// raw.php — escaping is not wired in the raw output syntax
require_once 'template.php';

file_put_contents('templates/raw.html', "<p>{{! summary }}</p>\n");
file_put_contents('templates/escaped.html', "<p>{{ summary }}</p>\n");
$engine = new TemplateEngine('templates', 'cache');
$data = ['summary' => '<b>bold</b>'];

foreach (['escaped', 'raw'] as $name) {
    Ledger::newRequest();
    echo $engine->show($name, $data);
    echo "$name\n";
    report(['escaping']);
}
```

```
<p>&lt;b&gt;bold&lt;/b&gt;</p>
escaped
  escaping   ran
<p><b>bold</b></p>
raw
  escaping   not wired
```

In raw output, escaping is **not wired**. The compiler never put the escaping call at that point; `onPath()` was not written, and neither was `ran()`. This is why the outcome is not **skipped**. Escaping runs everywhere except at the raw output point, and the deviation is limited to that point. There is no error in the output and no warning; the page is produced. Raw output exists to print trusted markup, but a person who puts the marker on an unescaped value disables the step with their own hand. For this reason raw output is an explicitly chosen exception, and its syntax is deliberately different: it should be distinguishable even when read out of the corner of the eye.

#### Summary

- A template engine turns a template into PHP code and writes the compiled file to a cache; layouts, components and loops are part of this translation.
- This lesson's outcome is **ran**: because the escaping step is embedded in the code the compiler generates, it ran at every `{{ }}` point.
- In an unchanged template the compilation step is **skipped**; escaping **ran** on every request regardless of compilation.
- In the raw output syntax escaping is **not wired**: the outcome is not **skipped**, because the part was never placed on the path.
- Raw output is an explicitly chosen exception; its danger is that it gives no error.

#### Next Step

A template trusts its data and prints every value given to it. But when a form submitted by a user does not satisfy a rule, does it reach the save step? The next lesson, Forms and Validation, measures validation and examines the save step falling into the **skipped** outcome.

### Forms and Validation

Source: https://academia.sh/en/courses/web-application-framework/forms-and-validation

A validator that works with rule strings is built; the lesson measures that the save step is skipped on invalid input, runs on valid input, and that stopping at the first error skips the next rule.

A template prints every value placed in front of it; it does not check whether the value is in the form it should be. That check is made in the form's action: if the input coming from the user does not satisfy the rule, it must not reach the save step. But how would you know that it did not reach it? In this lesson the question is what happens to the save step in a request where the rule does not hold.

The measurement axis is the same as in the previous lesson: did the expected part run in this request. The decision order is the `classify()` function itself: **ran**, **deferred**, **skipped**, **not wired**. This lesson's outcome is **skipped**: the save step is placed on the action's path, but it is not called. A skipped step shows to the client as a status code; here that code is 422.

**VD4.** Form input is an array decoded from the request body; the HTTP layer is not built in this lesson, and the action takes an array directly. **VD5.** A rule string consists of rules separated by `|`, and a rule's argument is given with `:` (`min:3`). **VD6.** Invalid input is shown with an empty (`''`) or short (`'ab'`) field; the logic of the rules is independent of special characters. **VD7.** Error messages are written in ASCII so that column widths do not break.

The measurement setup comes unchanged.

```php
<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}
```

#### Building the Validator

A validator parses the rule string for each field, runs each rule in order, and collects a message for the ones that fail. Two details matter for the measurement. First, all the rules of a field are placed on the path before any rule runs; so a rule that does not run can fall into the **skipped** outcome. Second, the option in the constructor determines stopping at the first error: if it stops, the later rules of the same field are not called.

The form action calls the validator. If the input is not valid, 422 is returned, and the error messages and the old input are put in the response; the old input keeps the fields from being emptied when the form is shown again. If it is valid, the save step runs and 201 is returned. The save step is placed on the action's path when the action starts, and its call is made only after validation passes.

```php
<?php
// validation.php — a validator with rule strings and an action that saves a post
declare(strict_types=1);
require_once 'ran.php';

final class Validator {
    public array $errors = [];

    public function __construct(private bool $stopOnFirstError = true) {}

    public function validate(array $input, array $rules): bool {
        $this->errors = [];
        foreach ($rules as $field => $spec) {
            $list = explode('|', $spec);
            foreach ($list as $rule) {
                Ledger::onPath("rule:$field:" . explode(':', $rule)[0]);
            }
            foreach ($list as $rule) {
                if ($this->stopOnFirstError && isset($this->errors[$field])) break;
                [$name, $argument] = array_pad(explode(':', $rule, 2), 2, null);
                Ledger::ran("rule:$field:$name");
                $value = (string) ($input[$field] ?? '');
                $passed = match ($name) {
                    'required' => $value !== '',
                    'min' => mb_strlen($value) >= (int) $argument,
                };
                if (!$passed) {
                    $this->errors[$field][] = match ($name) {
                        'required' => "$field is required",
                        'min' => "$field must be at least $argument characters",
                    };
                }
            }
        }
        return $this->errors === [];
    }
}

function savePost(array $input, Validator $validator, array $rules): array {
    Ledger::onPath('save');
    if (!$validator->validate($input, $rules)) {
        return [422, ['errors' => $validator->errors, 'old' => $input]];
    }
    Ledger::ran('save');
    return [201, ['saved' => $input['title']]];
}

function printResponse(array $response): void {
    [$status, $body] = $response;
    echo "status $status\n";
    foreach ($body['errors'] ?? [] as $field => $messages) {
        foreach ($messages as $message) echo "  error $field: $message\n";
    }
    foreach ($body['old'] ?? [] as $field => $value) echo "  old $field='$value'\n";
    if (isset($body['saved'])) echo "  saved {$body['saved']}\n";
}
```

#### The Save Step on Invalid Input

The measurement calls the action with an empty title and a two-letter summary. The rules are: title required, summary at least three characters. Expected: status 422, two error messages, the old input in the response, and the save step **skipped**. The rule parts are both **ran**, because both ran and both failed to hold.

```php
<?php
// invalid.php — on invalid input the save step is skipped
require_once 'validation.php';

$rules = ['title' => 'required', 'summary' => 'min:3'];
Ledger::newRequest();
printResponse(savePost(['title' => '', 'summary' => 'ab'], new Validator(), $rules));
foreach (['rule:title:required', 'rule:summary:min', 'save'] as $part) {
    printf("  %-22s %s\n", $part, classify($part));
}
```

```
status 422
  error title: title is required
  error summary: summary must be at least 3 characters
  old title=''
  old summary='ab'
  rule:title:required    ran
  rule:summary:min       ran
  save                   skipped
```

Although two error messages went out, the save step is **skipped**: it was placed on the action's path and not called. This skipping is not silent; the client receives 422 and sees which field failed and why. A rule that **ran** and a rule that did not hold are different things: the measurement axis is whether the rule ran, not whether it passed. The error message is a result of the rule having run.

#### The Opposing Measurement: Valid Input

The same action is called with valid input. Expected: status 201, no errors, and the save step **ran**.

```php
<?php
// valid.php — on valid input the save step runs
require_once 'validation.php';

$rules = ['title' => 'required', 'summary' => 'min:3'];
Ledger::newRequest();
printResponse(savePost(['title' => 'First post', 'summary' => 'abc'], new Validator(), $rules));
foreach (['rule:title:required', 'rule:summary:min', 'save'] as $part) {
    printf("  %-22s %s\n", $part, classify($part));
}
```

```
status 201
  saved First post
  rule:title:required    ran
  rule:summary:min       ran
  save                   ran
```

Validation itself ran in the same way in both requests; only the fate of the step behind it changed. The save step is not a gate independent of validation; it is a branch that depends on the result of validation.

#### The Bounding Measurement

The option in the validator's constructor determines how many messages go to the user on the same input. Let the summary field have two rules (`required|min:3`) and let the input be empty. In the validator that does not stop at the first error, both rules run and two messages are collected. In the validator that stops at the first error, `required` fails, `min` is not called, and a single message is collected.

```php
<?php
// stopping.php — stopping at the first error makes the next rule skipped
require_once 'validation.php';

$rules = ['title' => 'required', 'summary' => 'required|min:3'];
foreach ([true, false] as $stop) {
    Ledger::newRequest();
    echo $stop ? "stops at first error\n" : "does not stop\n";
    printResponse(savePost(['title' => 'First post', 'summary' => ''], new Validator($stop), $rules));
    foreach (['rule:summary:required', 'rule:summary:min', 'save'] as $part) {
        printf("  %-22s %s\n", $part, classify($part));
    }
}
```

```
stops at first error
status 422
  error summary: summary is required
  old title='First post'
  old summary=''
  rule:summary:required  ran
  rule:summary:min       skipped
  save                   skipped
does not stop
status 422
  error summary: summary is required
  error summary: summary must be at least 3 characters
  old title='First post'
  old summary=''
  rule:summary:required  ran
  rule:summary:min       ran
  save                   skipped
```

In the validator that stops, the `min` rule is **skipped**, and in the one that does not stop it **ran**; the status of the response is 422 in both cases and the save step is **skipped** in both. Whether the rules hold or not determines the fate of the save step, while the choice to stop determines the number of messages. Telling an empty field "at least three characters" as well gives the user no new information; this is why stopping at the first error is a common choice. But a form that wants to show all the problems of a field in one round builds the second option. The choice is a design decision, and it is visible in the response.

#### Summary

- The validator parses rule strings, collects error messages and returns the old input to the form.
- This lesson's outcome is **skipped**: on invalid input the save step was not called although it had been placed on the action's path; the client received 422 and the messages.
- In the opposing measurement, valid input brought the save step to the **ran** outcome and the status became 201.
- A rule running (**ran**) is separate from a rule passing; a rule that does not hold has also run.
- Stopping at the first error makes the next rule in the same field **skipped** and reduces the number of messages sent to the user; the fate of the save step does not change.

#### Next Step

Valid input reached the save step. When the save goes through a model, what else runs while it is saved? The next lesson, Object–Table Binding, covers object–relational mapping and measures which part ran while the model was saved.

### Object–Table Binding

Source: https://academia.sh/en/courses/web-application-framework/object-and-table-binding

A small model class that keeps the table as a PHP array is built; the lesson measures that the save hook runs on save, that a field outside the allow list is not wired in mass assignment, and that the hook is not wired in a bulk update.

The form validation passed and the save step ran. But when the save is made through a model, is only a row written while it is saved? The model's own hooks, accessors and field rules run too; seeing which of them ran is the subject of this lesson.

Object–relational mapping is a mechanism that binds a class to a table: an instance of the class is a row of the table. What this binding produces at the source was measured in the lesson of the same name in M08/K06; in this lesson the question is different: while the model is saved, which part ran in this request. The decision order is the `classify()` function itself, and this lesson's outcome is **ran**; the measurements are compared with **not wired**.

**VD8.** The table is a PHP array, so the data layer is nothing more than an array of rows; there is no real database connection. **VD9.** Field names are ASCII, and `id` is derived from the length of the table at save time. **VD10.** For the mass assignment counterpart, a neutral counter field named `views` is used; the field carries no permission or role. **VD11.** Slug generation keeps only ASCII letters and digits.

The measurement setup comes unchanged.

```php
<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}
```

#### Building the Model

The model base knows three things: which table it writes to, which fields can be filled in bulk, and which hook it will call while saving. A lifecycle hook is a method called before or after an event; here `saving` is called right before the record is written to the table. An accessor is a method that transforms a field's value when it is read; here `readSummaryShort` is read under the name `summaryShort`. A query scope names a frequently used filter; `published()` returns only the published rows.

`fill()` takes only the fields in the allow list. For a field outside the allow list there is neither an error nor a log entry: the field is never placed on the part's path. The opposing measurement of this lesson rests on this silence.

```php
<?php
// model.php — a small model with a table array, a scope, an accessor and a hook
declare(strict_types=1);
require_once 'ran.php';

final class Data {
    public static array $tables = ['posts' => []];
    public static function reset(): void { self::$tables = ['posts' => []]; }
}

abstract class Model {
    protected static string $table;
    protected static array $fillable = [];
    public array $attributes = [];

    public static function create(array $input): static {
        return (new static())->fill($input)->save();
    }

    public function fill(array $input): static {
        foreach ($input as $field => $value) {
            if (in_array($field, static::$fillable, true)) {
                Ledger::onPath("field:$field");
                Ledger::ran("field:$field");
                $this->attributes[$field] = $value;
            }
        }
        return $this;
    }

    public function save(): static {
        Ledger::onPath('hook:saving');
        $this->saving();
        $this->attributes['id'] = count(Data::$tables[static::$table]) + 1;
        Data::$tables[static::$table][] = $this->attributes;
        return $this;
    }

    protected function saving(): void {}

    public static function bulkUpdate(array $values, array $condition): int {
        $count = 0;
        foreach (Data::$tables[static::$table] as &$row) {
            if (array_intersect_assoc($condition, $row) === $condition) {
                $row = $values + $row;
                $count++;
            }
        }
        return $count;
    }

    public function __get(string $name): mixed {
        $accessor = 'read' . ucfirst($name);
        return method_exists($this, $accessor) ? $this->$accessor() : ($this->attributes[$name] ?? null);
    }
}

final class Post extends Model {
    protected static string $table = 'posts';
    protected static array $fillable = ['title', 'summary', 'published'];

    protected function saving(): void {
        Ledger::ran('hook:saving');
        $title = strtolower((string) $this->attributes['title']);
        $this->attributes['slug'] = trim(preg_replace('/[^a-z0-9]+/', '-', $title), '-');
    }

    protected function readSummaryShort(): string {
        return mb_substr((string) $this->attributes['summary'], 0, 5) . '...';
    }

    public static function published(): array {
        return array_values(array_filter(Data::$tables['posts'], fn($row) => $row['published'] ?? false));
    }
}

function report(array $parts): void {
    foreach ($parts as $part) printf("  %-20s %s\n", $part, classify($part));
}
```

#### Seeing the Save Hook Run

The measurement creates two posts: one published, the other not. Expected: the save hook **ran**, the slug `first-post` was generated from the title, the scope returns only the published row, and the accessor shortens the summary. All three fields in the allow list **ran**.

```php
<?php
// observe.php — the save hook, the scope and the accessor
require_once 'model.php';

Data::reset();
Ledger::newRequest();
$post = Post::create(['title' => 'First Post', 'summary' => 'Short summary text', 'published' => true]);
Post::create(['title' => 'Draft', 'summary' => 'Not ready yet', 'published' => false]);
echo "slug={$post->slug}\n";
echo "summaryShort={$post->summaryShort}\n";
echo "published=" . count(Post::published()) . " / total=" . count(Data::$tables['posts']) . "\n";
report(['field:title', 'field:summary', 'field:published', 'hook:saving']);
```

```
slug=first-post
summaryShort=Short...
published=1 / total=2
  field:title          ran
  field:summary        ran
  field:published      ran
  hook:saving          ran
```

The hook ran on its own inside the call that writes the record; the caller did not need to do anything, and the slug was generated on its own. This is both the benefit and the fragility of the hook: the behavior sits in a place the caller cannot see. The scope and the accessor left no separate run record, because they are methods that work by being called, and what is measured is whether a step took part in the request.

#### The Opposing Measurement: A Field Outside the Allow List

Mass assignment is the bulk transfer of the fields in a request's body to the model; the allow list is the boundary of this transfer. The measurement adds the `views` field, which is not in the allow list, to the request. Expected: the `views` field is **not wired**, because it was never placed on the path; the value does not enter the record; there is no error.

```php
<?php
// mass.php — a field outside the allow list is not wired in mass assignment
require_once 'model.php';

Data::reset();
Ledger::newRequest();
$post = Post::create(['title' => 'Second', 'summary' => 'Text', 'published' => true, 'views' => 999]);
echo "views in record: " . (array_key_exists('views', Data::$tables['posts'][0]) ? 'present' : 'absent') . "\n";
report(['field:title', 'field:views']);
```

```
views in record: absent
  field:title          ran
  field:views          not wired
```

`views` was in the request and is absent from the record. The outcome is **not wired**, not **skipped**: a part that is placed on the path and not called would be **skipped**; here the allow list left the field outside the step of placing it on the path. That no error is given means that in mass assignment, silence can be both protective and deceptive. The answer to the question of why the field did not enter is in the allow list, not in the output.

#### The Bounding Measurement

The hook runs on the save path. But what if there is an update path that bypasses the save path? `bulkUpdate()` changes the table rows directly, and `save()` is not called. The measurement first creates a post, then changes its title with `bulkUpdate()`. Expected: the hook is **not wired**, and the slug stays at the old title.

```php
<?php
// bulk.php — a bulk update does not run the save hook
require_once 'model.php';

Data::reset();
Post::create(['title' => 'First Post', 'summary' => 'Text', 'published' => true]);
Ledger::newRequest();
$count = Post::bulkUpdate(['title' => 'New Title'], ['id' => 1]);
$row = Data::$tables['posts'][0];
echo "updated=$count\n";
echo "title={$row['title']} slug={$row['slug']}\n";
report(['hook:saving']);
```

```
updated=1
title=New Title slug=first-post
  hook:saving          not wired
```

In the output the hook is **not wired**: the title changed, the slug did not. The slug is a derived value that depends on the title, and it becomes inconsistent when the hook is bypassed. A hook that is not wired gives no error, so it does not show in the response either. This outcome is dangerous: there is no trace where the behavior is expected. The solution is to pass the change through the save path, or to have the bulk update write the derived field itself; which one is chosen is the caller's decision.

#### Summary

- A model binds a class to a table; the scope, the accessor and the hook are added on top of this binding.
- This lesson's outcome is **ran**: the save hook ran on its own on the save path and generated a slug from the title.
- A field outside the allow list is **not wired** in mass assignment: the value did not enter the record and no error was given.
- In a bulk update that bypasses the save path, the hook is **not wired** and the derived field stayed old.
- Silence can be protective or deceptive; why a field did not enter the record is in the allow list, not in the output.

#### Next Step

The model handled a single table. When the author of a post sits in another table, when does the relationship query run? The next lesson, Relationships, measures relationships and lazy loading, and covers the outcome in which the relationship query is **deferred**.

### Relationships

Source: https://academia.sh/en/courses/web-application-framework/relationships

One-to-one, one-to-many and many-to-many relationships are built in a small model; the lesson measures that with lazy loading the relationship query is deferred until access, that with eager loading it runs together with the list, and that a relationship never accessed stays deferred.

We saw which part ran while a model was saved. The author of a post, however, sits in another table, and when the query that reaches it runs cannot be read from how the model is written. The question is this: when the posts are listed, does the query that fetches their authors run at that moment, or when the author is first accessed?

The measurement axis of this lesson is under what condition the relationship query ran in this request. The decision order is the `classify()` function itself: **ran**, **deferred**, **skipped**, **not wired**. The dominant outcome is **deferred**: the part is left outside the request and does not run until it is accessed. The number of queries will also be printed, but this is a different thing from M10/K03: there the number of runs was measured; here the number does not determine the outcome. The outcome comes from the `classify()` output, which says why and under what condition the relationship ran; the number is only the evidence placed beside it.

**VD12.** The tables are PHP arrays, and every `Store::select()` call counts as one query; the query counter is kept in the data layer itself. **VD13.** With lazy loading the `Ledger::defer()` record is written when the object is created; that is, the relationship counts as deferred from the moment the object exists. **VD14.** Ten posts are distributed among three authors, and there is no query cache. **VD15.** With eager loading the relationship query runs as a single query right after the list's query.

The measurement setup comes unchanged.

```php
<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}
```

#### Building the Relationships

A relationship is a method that goes from one model to another. In a one-to-one relationship an author has a single profile. In a one-to-many relationship an author has many posts, and each post has a single author. In a many-to-many relationship a post has many tags and a tag has many posts, and the relationship is kept in a pivot table. Lazy loading queries the relationship on first access; eager loading fetches the relationship together with the list query, with a single extra query.

The file below builds the three relationship types and the two loading styles. If the author was not loaded beforehand, a `Post` object writes the `relation:author` record as deferred while it is created; when the author is accessed the query runs and the record becomes **ran**. If it was loaded beforehand, the list loader writes the record as **ran** immediately.

```php
<?php
// relationship.php — relationship methods, lazy and eager loading
declare(strict_types=1);
require_once 'ran.php';

final class Store {
    public static int $queries = 0;
    public static array $tables = [
        'authors'  => [['id' => 1, 'name' => 'Alice'], ['id' => 2, 'name' => 'Mark'], ['id' => 3, 'name' => 'Dana']],
        'profiles' => [['author_id' => 1, 'site' => 'alice.test'], ['author_id' => 2, 'site' => 'mark.test']],
        'tags'     => [['id' => 1, 'name' => 'php'], ['id' => 2, 'name' => 'web']],
        'post_tag' => [['post_id' => 1, 'tag_id' => 1], ['post_id' => 1, 'tag_id' => 2], ['post_id' => 2, 'tag_id' => 1]],
        'posts'    => [],
    ];

    public static function select(string $table, callable $condition): array {
        self::$queries++;
        return array_values(array_filter(self::$tables[$table], $condition));
    }
}
Store::$tables['posts'] = array_map(
    fn($i) => ['id' => $i, 'title' => "Post $i", 'author_id' => ($i % 3) + 1],
    range(1, 10)
);

final class Author {
    public function __construct(public array $row) {}
    public function profile(): ?array {      // one-to-one
        return Store::select('profiles', fn($p) => $p['author_id'] === $this->row['id'])[0] ?? null;
    }
    public function posts(): array {         // one-to-many
        return Store::select('posts', fn($p) => $p['author_id'] === $this->row['id']);
    }
}

final class Post {
    private array $loaded = [];

    public function __construct(public array $row, ?array $author = null) {
        if ($author === null) Ledger::defer('relation:author');
        else $this->loaded['author'] = $author;
    }

    public function author(): array {        // the post's single author
        if (!isset($this->loaded['author'])) {
            Ledger::ran('relation:author');
            $this->loaded['author'] = Store::select('authors', fn($a) => $a['id'] === $this->row['author_id'])[0];
        }
        return $this->loaded['author'];
    }

    public function tags(): array {          // many-to-many, through the pivot table
        $ids = array_column(Store::select('post_tag', fn($r) => $r['post_id'] === $this->row['id']), 'tag_id');
        return Store::select('tags', fn($t) => in_array($t['id'], $ids, true));
    }

    public static function listAll(array $load = []): array {
        $rows = Store::select('posts', fn() => true);
        $authors = null;
        if (in_array('author', $load, true)) {
            Ledger::ran('relation:author');
            $ids = array_unique(array_column($rows, 'author_id'));
            $authors = array_column(Store::select('authors', fn($a) => in_array($a['id'], $ids, true)), null, 'id');
        }
        return array_map(fn($r) => new self($r, $authors[$r['author_id']] ?? null), $rows);
    }
}

function newRequest(): void { Ledger::newRequest(); Store::$queries = 0; }
function report(array $parts): void {
    foreach ($parts as $part) printf("  %-16s %s\n", $part, classify($part));
    echo "  queries=" . Store::$queries . "\n";
}
```

First, look at the results of the three relationship types; there is no outcome here, only the evidence that the relationships work.

```php
<?php
// types.php — the three relationship types
require_once 'relationship.php';

$authors = array_map(fn($r) => new Author($r), Store::$tables['authors']);
echo "profile: " . $authors[0]->profile()['site'] . "\n";
echo "author 1 post count: " . count($authors[0]->posts()) . "\n";
$post = Post::listAll()[0];
echo "post 1 tags: " . implode(',', array_column($post->tags(), 'name')) . "\n";
```

```
profile: alice.test
author 1 post count: 3
post 1 tags: php,web
```

#### Lazy Loading

The measurement lists ten posts and does not yet look at their authors. Expected: the list query has run, the relationship is **deferred**, and the number of queries is 1. Then a loop accesses the author of every post. Expected: the relationship is **ran** and the number of queries is 1 + 10.

```php
<?php
// lazy.php — lazy loading and the N+1 problem
require_once 'relationship.php';

newRequest();
$posts = Post::listAll();
echo "listed: " . count($posts) . " posts\n";
report(['relation:author']);
foreach ($posts as $post) $post->author();
echo "authors accessed\n";
report(['relation:author']);
```

```
listed: 10 posts
  relation:author  deferred
  queries=1
authors accessed
  relation:author  ran
  queries=11
```

When the list ran, the relationship query had not yet been executed: **deferred**, with one query. The loop ran a separate author query for each post; this is the N+1 problem: one list query, and one more query for every row in the list. The same author is queried more than once, because there is no cache. The number shows the problem; the outcome shows the cause of the problem: the relationship had been deferred until access, and the access was inside a loop.

#### The Opposing Measurement: Eager Loading

The same list is loaded by asking for the author relationship. Expected: the relationship **ran** together with the list, even before access, and the number of queries is 2.

```php
<?php
// eager.php — with eager loading the relationship runs together with the list
require_once 'relationship.php';

newRequest();
$posts = Post::listAll(['author']);
echo "listed: " . count($posts) . " posts\n";
report(['relation:author']);
foreach ($posts as $post) $post->author();
echo "authors accessed\n";
report(['relation:author']);
```

```
listed: 10 posts
  relation:author  ran
  queries=2
authors accessed
  relation:author  ran
  queries=2
```

The relationship **ran** without any post's author being looked at; the number of queries did not change after the loop. Its cost is that the query runs even if the authors are never looked at. The choice between lazy loading and eager loading depends on whether it is known that the relationship will be accessed.

#### The Bounding Measurement

Does a relationship that is not accessed stay deferred, or is it not wired? The answer depends on where the record is written. Here `Ledger::defer()` is written when the object is created, so the relationship counts as part of the existing object. In a request in which no post's author is accessed, `relation:author` still stays **deferred**. In the same request, a relationship whose name is written nowhere, for example `relation:profile`, leaves no record and falls into the **not wired** outcome.

```php
<?php
// unaccessed.php — an unaccessed relationship stays deferred, one with no record is not wired
require_once 'relationship.php';

newRequest();
$posts = Post::listAll();
echo "authors never accessed\n";
report(['relation:author', 'relation:profile']);
```

```
authors never accessed
  relation:author  deferred
  relation:profile not wired
  queries=1
```

`relation:author` is **deferred**, `relation:profile` is **not wired**. Neither ever ran, but the difference between them is large: a deferred relationship knows that it can run with an access; a part that is not wired never had such a path defined. In a design that writes the record not when the object is created but during access, `relation:author` would also be **not wired** in this request. So the outcome is information that depends on the choice of the record point, not on the relationship itself; this choice was given explicitly at the beginning of the lesson.

#### Summary

- One-to-one, one-to-many and many-to-many relationships are each a model method; the many-to-many relationship uses the pivot table.
- This lesson's outcome is **deferred**: with lazy loading, the relationship query did not run until it was accessed, even after the list query.
- Access in a loop produced the N+1 problem; the number of queries showed the problem, and the outcome showed the cause of the deferral.
- With eager loading the relationship **ran** together with the list; its cost is that the query runs even if it is not accessed.
- A relationship that is never accessed stays **deferred**; a relationship with no record is **not wired**.

#### Next Step

Relationships rest on tables, and tables come from somewhere. What happens when the migration that builds the schema runs a second time? The next lesson, Migrations and Seeding, measures migrations and seeding, and shows that on the second run the migration falls into the **skipped** outcome.

### Migrations and Seeding

Source: https://academia.sh/en/courses/web-application-framework/migrations-and-seeding

Migrations that version the schema and a seeder that writes starting data are built; the lesson measures that a recorded migration is skipped on the second run, that a new migration runs, and that editing a migration file that has already run is not wired.

Relationships rest on tables, and the tables have to come from somewhere. If the schema is built by hand, it comes out different in every environment; migrations exist to build the schema step by step and in a repeatable way. But if the same migration is run twice, is the table built twice?

The measurement axis of this lesson is whether the migration file ran in this run. The decision order is the `classify()` function itself: **ran**, **deferred**, **skipped**, **not wired**. The dominant outcome is **skipped**: the migration file was found and placed on the path, but it was not run because it is in the log.

**VD16.** The schema is kept in the file `schema.json` in the working directory as an array from table names to column lists; there is no real database. **VD17.** The migration log keeps the names of the migrations that have run, in order, in `migration_log.json`. **VD18.** Migration files run sorted by name under `migrations/`, and each returns the `up` and `down` functions. **VD19.** The seeder writes data only when the table is empty; the starting data is in `data.json`.

The measurement setup comes unchanged.

```php
<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}
```

#### The Migration Runner

A migration is a file that moves the schema from one version to another. The `up` function applies the change, and the `down` function reverses it. The migrator goes through the migration files in order, does not run the ones in the log, and runs the ones not in the log and adds them to the log. A rollback calls the `down` function of the last migration added to the log and deletes the log entry. Seeding writes starting data on top of the schema. Adding a column in a migration file goes through `addColumn()`, and each addition records itself under the name `column:<table>.<column>`.

```php
<?php
// migration.php — a migration runner, a migration log and a seeder
declare(strict_types=1);
require_once 'ran.php';

function readJson(string $file): array { return is_file($file) ? json_decode(file_get_contents($file), true) : []; }
function writeJson(string $file, array $data): void { file_put_contents($file, json_encode($data)); }

function addColumn(array $schema, string $table, string $column): array {
    Ledger::ran("column:$table.$column");
    $schema[$table][] = $column;
    return $schema;
}
function dropColumn(array $schema, string $table, string $column): array {
    $schema[$table] = array_values(array_diff($schema[$table], [$column]));
    return $schema;
}

final class Migrator {
    public function up(): void {
        $log = readJson('migration_log.json');
        $schema = readJson('schema.json');
        $files = glob('migrations/*.php');
        sort($files);
        foreach ($files as $file) {
            $name = basename($file, '.php');
            Ledger::onPath("migration:$name");
            if (in_array($name, $log, true)) continue;
            Ledger::ran("migration:$name");
            $migration = require $file;
            $schema = $migration['up']($schema);
            $log[] = $name;
        }
        writeJson('schema.json', $schema);
        writeJson('migration_log.json', $log);
    }

    public function rollBack(): void {
        $log = readJson('migration_log.json');
        $schema = readJson('schema.json');
        $name = array_pop($log);
        $migration = require "migrations/$name.php";
        Ledger::ran("down:$name");
        writeJson('schema.json', $migration['down']($schema));
        writeJson('migration_log.json', $log);
    }
}

final class Seeder {
    public function run(): void {
        Ledger::onPath('seed:posts');
        $data = readJson('data.json');
        if (!empty($data['posts'])) return;
        Ledger::ran('seed:posts');
        $data['posts'] = [['id' => 1, 'title' => 'First Post'], ['id' => 2, 'title' => 'Second Post']];
        writeJson('data.json', $data);
    }
}

function resetState(): void {
    foreach (['schema.json', 'migration_log.json', 'data.json'] as $file) if (is_file($file)) unlink($file);
    if (!is_dir('migrations')) mkdir('migrations');
    foreach (glob('migrations/*.php') as $file) unlink($file);
}
function report(array $parts): void {
    foreach ($parts as $part) printf("  %-24s %s\n", $part, classify($part));
}
```

Three migration files are written by an author script. The first builds the `posts` table, the second adds the `slug` column, and the third builds the `tags` table; the third is added later in the opposing measurement.

```php
<?php
// migrations.php — writes the example migration files
function writeMigration(string $name): void {
    $source = match ($name) {
        '001_posts' => <<<'MIGRATION'
<?php
return [
    'up' => function (array $schema): array {
        $schema['posts'] = [];
        return addColumn(addColumn($schema, 'posts', 'id'), 'posts', 'title');
    },
    'down' => fn(array $schema): array => array_diff_key($schema, ['posts' => 1]),
];
MIGRATION,
        '002_slug' => <<<'MIGRATION'
<?php
return [
    'up' => fn(array $schema): array => addColumn($schema, 'posts', 'slug'),
    'down' => fn(array $schema): array => dropColumn($schema, 'posts', 'slug'),
];
MIGRATION,
        '003_tags' => <<<'MIGRATION'
<?php
return [
    'up' => function (array $schema): array {
        $schema['tags'] = [];
        return addColumn(addColumn($schema, 'tags', 'id'), 'tags', 'name');
    },
    'down' => fn(array $schema): array => array_diff_key($schema, ['tags' => 1]),
];
MIGRATION,
    };
    file_put_contents("migrations/$name.php", $source);
}
```

#### Skipping a Recorded Migration

The measurement runs the two migrations twice, and calls the seeder after each run. Expected: in the first run the two migrations and the seeding are **ran**, in the second all three are **skipped**; the schema does not change in the second run.

```php
<?php
// observe.php — on the second run the recorded migrations are skipped
require_once 'migration.php';
require_once 'migrations.php';

resetState();
writeMigration('001_posts');
writeMigration('002_slug');
foreach ([1, 2] as $n) {
    Ledger::newRequest();
    (new Migrator())->up();
    (new Seeder())->run();
    echo "run $n\n";
    report(['migration:001_posts', 'migration:002_slug', 'seed:posts']);
}
echo "schema: " . json_encode(readJson('schema.json')) . "\n";
```

```
run 1
  migration:001_posts      ran
  migration:002_slug       ran
  seed:posts               ran
run 2
  migration:001_posts      skipped
  migration:002_slug       skipped
  seed:posts               skipped
schema: {"posts":["id","title","slug"]}
```

In the second run the migration files were found and placed on the path, and they were not called because they are in the log: **skipped**. This is not an error; it is the mechanism that makes a migration resistant to repetition. The seeder was likewise skipped because the table was full; otherwise two posts would have become four.

#### The Opposing Measurement: A New Migration and a Rollback

A fourth migration is added to a schema on which three migrations have run. Expected: the new migration is **ran**, and the earlier ones are **skipped**. Then the last migration is rolled back and run again; because the rolled-back migration has left the log, it should be **ran** again.

```php
<?php
// new_migration.php — a new migration runs, a rolled-back migration runs again
require_once 'migration.php';
require_once 'migrations.php';

resetState();
writeMigration('001_posts');
writeMigration('002_slug');
(new Migrator())->up();

writeMigration('003_tags');
Ledger::newRequest();
(new Migrator())->up();
echo "new migration added\n";
report(['migration:001_posts', 'migration:002_slug', 'migration:003_tags']);

Ledger::newRequest();
(new Migrator())->rollBack();
echo "rolled back: " . json_encode(array_keys(readJson('schema.json'))) . "\n";
report(['down:003_tags']);

Ledger::newRequest();
(new Migrator())->up();
echo "ran again: " . json_encode(array_keys(readJson('schema.json'))) . "\n";
report(['migration:002_slug', 'migration:003_tags']);
```

```
new migration added
  migration:001_posts      skipped
  migration:002_slug       skipped
  migration:003_tags       ran
rolled back: ["posts"]
  down:003_tags            ran
ran again: ["posts","tags"]
  migration:002_slug       skipped
  migration:003_tags       ran
```

The migration runner found the new file on its own; nobody called it by name. The rollback reversed the log and the schema together, which is why the migration **ran** again. The distinction between what is skipped and what runs is in the log, not in the file.

#### The Bounding Measurement

If the file of a migration is edited after it has run, does the runner notice? The measurement changes the `002_slug` file while the migration is in its already-run state, and adds a second column, `summary`. The runner is run again. Expected: the migration is **skipped**, because it is in the log; the edit is **not wired**, because it left no run record. `summary` will not be in the schema. The way to correct this is to make the change with a new migration file; this is tried at the end of the measurement.

```php
<?php
// edit.php — editing a migration file that has run is not wired
require_once 'migration.php';
require_once 'migrations.php';

resetState();
writeMigration('001_posts');
writeMigration('002_slug');
(new Migrator())->up();

file_put_contents('migrations/002_slug.php', <<<'MIGRATION'
<?php
return [
    'up' => fn(array $schema): array => addColumn(addColumn($schema, 'posts', 'slug'), 'posts', 'summary'),
    'down' => fn(array $schema): array => dropColumn(dropColumn($schema, 'posts', 'slug'), 'posts', 'summary'),
];
MIGRATION);
Ledger::newRequest();
(new Migrator())->up();
echo "002 edited\n";
report(['migration:002_slug', 'column:posts.summary']);
echo "schema: " . json_encode(readJson('schema.json')) . "\n";

file_put_contents('migrations/004_summary.php', <<<'MIGRATION'
<?php
return [
    'up' => fn(array $schema): array => addColumn($schema, 'posts', 'summary'),
    'down' => fn(array $schema): array => dropColumn($schema, 'posts', 'summary'),
];
MIGRATION);
Ledger::newRequest();
(new Migrator())->up();
echo "004 added\n";
report(['migration:002_slug', 'migration:004_summary', 'column:posts.summary']);
echo "schema: " . json_encode(readJson('schema.json')) . "\n";
```

```
002 edited
  migration:002_slug       skipped
  column:posts.summary     not wired
schema: {"posts":["id","title","slug"]}
004 added
  migration:002_slug       skipped
  migration:004_summary    ran
  column:posts.summary     ran
schema: {"posts":["id","title","slug","summary"]}
```

The edited file is **skipped**: it was found, but it was not called because it is in the log. The `summary` column written into the file is **not wired**; it was neither placed on the path nor called, and it is not in the schema. That no error is given is the danger of this outcome: the developer thinks the column was added, and the environment stays on a different schema. A new migration file made the same column **ran** and brought it to the schema. For this reason a migration that has run is not changed; a schema change is always made with a new migration.

#### Summary

- A migration versions the schema; the runner runs the migrations not in the log and updates the log.
- This lesson's outcome is **skipped**: the recorded migration was found and placed on the path on the second run, but it was not called; the schema did not change.
- A newly added migration is **ran**; a rolled-back migration is **ran** again because it left the log.
- An edit in a migration file that has run is **not wired**: the file was skipped, the added column did not reach the schema, and no error occurred.
- The seeder is also **skipped** when the table is full; a schema change is always made with a new migration.

#### Next Step

The schema and the data are ready. When a new column is added to a model, does that column reach the API response on its own? The next lesson, Pagination and Resource Transformation, measures pagination and resource transformation, and shows that a column that is in the model but not in the response definition is **not wired**.

### Pagination and Resource Transformation

Source: https://academia.sh/en/courses/web-application-framework/pagination-and-resource-transformation

Offset pagination and a resource transformation from model to response array are built; the lesson measures that a new column added to the model is not wired to the response because it is not in the transformation definition, that it runs once added to the definition, and that a request outside the page range returns an empty list.

The migration added the new column to the schema. Does that column reach the API response on its own? The model knows the column, but the response is built somewhere else, in a transformation definition. If the link between the two is not made on its own, the column may never appear in the response even though it was added.

The measurement axis of this lesson is whether a column or a step ran in this response. The decision order is the `classify()` function itself: **ran**, **deferred**, **skipped**, **not wired**. The dominant outcome is **not wired**, and as in the core lesson it is the most dangerous one: a skipped step shows as a status code and a deferred step shows in the worker; a step that is not wired shows nowhere, and the response returns 200.

**VD20.** The table is a PHP array of twenty-five rows, and the rows carry `id`, `title`, `summary` and the later-added `reading_time` column; `reading_time` is a neutral number. **VD21.** Pagination is offset based: the page number starts at one, and the number of skipped rows is `(page - 1) * size`. **VD22.** The response status returns as a `[status, body]` pair, with a small slice of the action; the HTTP layer is not built. **VD23.** The resource transformation takes every field in the definition from the model in order.

The measurement setup comes unchanged.

```php
<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}
```

#### Pagination and the Resource

Pagination splits a long list into pages. Offset pagination takes the page number and the page size, skips the rows before the page, and returns as many rows as the page size; the total row count and the last page number are put in the response so that the client knows how many pages there are. Resource transformation is the layer that turns a model row into a response array. A resource collection applies this transformation to every row of a page and adds the page information as metadata (`meta`).

The transformation definition names which fields will enter the response. A field that is in the model and not in the definition neither enters the response nor produces an error. This is the purpose of resource transformation: the shape of the response does not change on its own when the model changes, so the transformation is a contract that protects the response from changes in the model. The same property also makes it possible to forget a new column.

```php
<?php
// pagination.php — offset pagination and resource transformation
declare(strict_types=1);
require_once 'ran.php';

function posts(): array {
    return array_map(
        fn($i) => ['id' => $i, 'title' => "Post $i", 'summary' => "Summary $i", 'reading_time' => ($i % 5) + 1],
        range(1, 25)
    );
}

function paginate(array $rows, int $page, int $size): array {
    Ledger::ran('pagination');
    $total = count($rows);
    return [
        'rows' => array_slice($rows, ($page - 1) * $size, $size),
        'page' => $page, 'size' => $size, 'total' => $total,
        'last' => (int) ceil($total / $size),
    ];
}

final class Resource {
    public function __construct(private array $fields) {}

    public function transform(array $row): array {
        $output = [];
        foreach ($this->fields as $field) {
            Ledger::onPath("field:$field");
            Ledger::ran("field:$field");
            $output[$field] = $row[$field];
        }
        return $output;
    }

    public function collection(array $page): array {
        return [
            'data' => array_map(fn($r) => $this->transform($r), $page['rows']),
            'meta' => ['page' => $page['page'], 'size' => $page['size'], 'total' => $page['total'], 'last' => $page['last']],
        ];
    }
}

function postsAction(Resource $resource, int $page, int $size = 10): array {
    return [200, $resource->collection(paginate(posts(), $page, $size))];
}

function printResponse(array $response): void {
    [$status, $body] = $response;
    $m = $body['meta'];
    echo "status $status\n";
    echo "  page={$m['page']} size={$m['size']} total={$m['total']} last={$m['last']}\n";
    echo "  records=" . count($body['data']) . "\n";
    if ($body['data'] !== []) echo "  keys=" . implode(',', array_keys($body['data'][0])) . "\n";
}
function report(array $parts): void {
    foreach ($parts as $part) printf("  %-18s %s\n", $part, classify($part));
}
```

#### Does the New Column Reach the Response

The table carries the `reading_time` column, but the transformation definition names only the `id`, `title` and `summary` fields. The measurement asks for the second page. Expected: status 200, ten records, a total of twenty-five, three as the last page; pagination **ran**, `title` **ran**, `reading_time` **not wired**.

```php
<?php
// new_column.php — a column not in the definition is not wired to the response
require_once 'pagination.php';

Ledger::newRequest();
printResponse(postsAction(new Resource(['id', 'title', 'summary']), 2));
report(['pagination', 'field:title', 'field:reading_time']);
```

```
status 200
  page=2 size=10 total=25 last=3
  records=10
  keys=id,title,summary
  pagination         ran
  field:title        ran
  field:reading_time not wired
```

The response is 200, the page information is right, and there are three keys. `reading_time` is in every row of the table, but it is not in the response, and there is no trace of this: no warning, no error. Because the transformation does not know the new column, the column was neither placed on the path nor called: **not wired**. The client goes on getting the old shape without knowing that the column was added; the developer who added the column sees from the test only that the old fields are present, and the test passes.

#### The Opposing Measurement: Adding It to the Definition

The same request is made with a resource that has `reading_time` added to its definition. Expected: the field **ran** and the number of keys is four.

```php
<?php
// defined.php — a column added to the definition reaches the response
require_once 'pagination.php';

Ledger::newRequest();
printResponse(postsAction(new Resource(['id', 'title', 'summary', 'reading_time']), 2));
report(['pagination', 'field:title', 'field:reading_time']);
```

```
status 200
  page=2 size=10 total=25 last=3
  records=10
  keys=id,title,summary,reading_time
  pagination         ran
  field:title        ran
  field:reading_time ran
```

The same page and the same page information; the difference is only in the definition. Once the column is added to the definition it **ran**. The shape of the response thus changes on purpose: a line in the definition is a change to the response contract, and it is visible in review. That a change in the model does not change the response on its own is the protection; its cost is that this definition is maintained by hand.

#### The Bounding Measurement

Does a request outside the page range return an empty list or an error? The table has twenty-five rows and the page size is ten; the last page is three. For comparison, the measurement asks for the third page, then the fourth and the ninety-ninth. Because pagination is done with `array_slice`, a page outside the range works without a special check.

```php
<?php
// out_of_range.php — a request outside the page range
require_once 'pagination.php';

foreach ([3, 4, 99] as $page) {
    Ledger::newRequest();
    echo "requested page $page\n";
    printResponse(postsAction(new Resource(['id', 'title', 'summary']), $page));
    report(['pagination', 'field:title']);
}
```

```
requested page 3
status 200
  page=3 size=10 total=25 last=3
  records=5
  keys=id,title,summary
  pagination         ran
  field:title        ran
requested page 4
status 200
  page=4 size=10 total=25 last=3
  records=0
  pagination         ran
  field:title        not wired
requested page 99
status 200
  page=99 size=10 total=25 last=3
  records=0
  pagination         ran
  field:title        not wired
```

In the output a page outside the range is not an error: the status is 200 and the record count is zero. The metadata returns the requested page number as it is, and the last page stays three. What stands out is the `field:title` record on an out-of-range page: because there were no rows, the transformation never ran, and the field fell into the **not wired** outcome. The same field **ran** on the third page. So the answer to the question "did this field run in the response" depends on whether there are rows in the response. A client can read an empty list as "there are no records on this page", but cannot tell it apart from an error; if an error is wanted, an explicit check comparing the page number with `last` is needed.

#### Summary

- Offset pagination produces the page, the page size, the total and the last page information; resource transformation turns the model into a response array.
- This lesson's outcome is **not wired**: the column added to the model did not reach the response because it was not in the transformation definition, the response returned 200, and no error was seen.
- A column added to the definition **ran**; a change in the shape of the response thus becomes a visible decision.
- Resource transformation is a contract that protects the response from changes in the model; adding the new column to the definition by hand is the price of this protection.
- A request outside the page range returned 200 and an empty list; the transformation never ran because there were no rows.

#### Next Step

The response is ready, but we have not yet looked at who is asking for it. What happens if a user without permission asks for the same list? The first lesson of the next topic, application services, Authentication and Authorization, takes up this question.

## Application Services

Authentication and authorization, queues, events, notifications, caching, file storage, scheduled tasks, localization and testing — each as a mechanism that decides whether a piece of work runs in the request, later, or not at all.

### Authentication and Authorization

Source: https://academia.sh/en/courses/web-application-framework/authentication-and-authorization

Storing and verifying a password as a hash, the user in the session, the gate and the policy; a legitimate user asking to edit someone else's post, the gate returning 403 and the action being skipped; the principle that an undefined ability is denied.

The Pagination and Resource Transformation lesson showed a new field that was not wired into the response: the model had changed, the response returned 200, but the change never reached the response body. This lesson opens the application services, and its question is: **can the person making the request do this work, and if not, did the action run?**

There are two separate questions, and frameworks place them in separate steps. **Authentication** establishes who is making the request: the username and password are correct, the session is tied to a user. **Authorization** decides whether the user whose identity is known may do this work. The first redirects to the `/login` page with 302; the second refuses with 403. Having authenticated someone does not give that person the right to edit every record.

**AS1.** The model uses no product name and does not read passwords from outside: a password is hashed with `password_hash()` and verified with `password_verify()`; the hash itself is never printed, because it produces a different value on every call. The session is a reduced form of the `session` property of the core's `Request` class: in this lesson the property carries the identity of the user in the session, and no `session_*` function is called. Password cracking, session theft, token forgery and privilege escalation are not modeled; the code that runs stays on the defensive side.

#### The Measurement Core

The same measurement is used in every lesson of the course. `ran.php` records whether an expected part ran in this request; the file is given verbatim in every lesson, and it has no output block, since it is only saved to disk. `Ledger` keeps three lists: the parts placed on the request's path, the parts called within the request, and the parts left outside the request. `classify()` checks them in order. **The decision order is `classify()` itself:** ran, deferred, skipped, not wired. The developer names the part; what is measured are the parts the developer expects to run in this request.

```php
<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}
```

This lesson connects to the core with three calls: `Ledger::onPath()` when it places a part on the path and `Ledger::ran()` when it calls the part; a deferred part does not appear in this lesson. The whole framework is not rebuilt; only this lesson's slice is written.

#### The Password Hash

The first step of authentication is the way a password is stored. A password is not stored as it is; `password_hash()` adds a salt to it and produces a one-way hash, and `password_verify()` compares a given password with the hash. The salt is new on every call, so two hashes of the same password differ but both verify. The measurement below does not print the hash itself, only the results of the comparisons.

```php
<?php
// password.php — two hashes of the same password
$first = password_hash('password-alice', PASSWORD_DEFAULT);
$second = password_hash('password-alice', PASSWORD_DEFAULT);
echo 'are the two hashes equal: ', $first === $second ? 'yes' : 'no', "\n";
echo 'does the first hash verify: ', password_verify('password-alice', $first) ? 'yes' : 'no', "\n";
echo 'does the second hash verify: ', password_verify('password-alice', $second) ? 'yes' : 'no', "\n";
echo 'does a wrong password verify: ', password_verify('password-bob', $first) ? 'yes' : 'no', "\n";
```

```
are the two hashes equal: no
does the first hash verify: yes
does the second hash verify: yes
does a wrong password verify: no
```

That the hashes are not equal says the comparison has to be made with `password_verify()`, not with `===`. For this reason `IdentityStore::login()` checks the username by equality and the password with the verification function. The store holds only the `passwordHash` property; the plain password is kept in no object.

#### The Gate and the Policy

`authorization.php` is the whole slice and has four parts. `IdentityStore` keeps the password as a hash and verifies it with `login()`. The **gate**, that is `Gate`, binds the name of an ability (`post.edit`) to a **policy**; a policy is a small function that takes the user and the target object and returns true or false. `PostPolicy::edit()` states the rule in one line: only the owner edits the post.

`PostApp::edit()` places three expected parts on the path: `identity` (finding the user in the session), `gate` (the permission question) and `edit` (the action that changes the title). Each step is marked with `Ledger::ran()` when it is called. If there is no identity, the request returns with 302; if the gate refuses, it returns with 403, and the action is never called.

```php
<?php
// authorization.php — authentication, the gate and the policy
declare(strict_types=1);
require_once 'ran.php';

final class User {
    public function __construct(public int $id, public string $name, public string $passwordHash) {}
}

final class Post {
    public function __construct(public int $id, public int $ownerId, public string $title) {}
}

final class Request {
    public function __construct(public ?int $session = null, public array $body = []) {}
}

final class IdentityStore {
    private array $users = [];
    public function register(int $id, string $name, string $password): void {
        $this->users[$id] = new User($id, $name, password_hash($password, PASSWORD_DEFAULT));
    }
    public function login(string $name, string $password): ?User {
        foreach ($this->users as $user) {
            if ($user->name === $name && password_verify($password, $user->passwordHash)) return $user;
        }
        return null;
    }
    public function find(int $id): ?User { return $this->users[$id] ?? null; }
}

final class Gate {
    private array $policies = [];
    public function __construct(private bool $undefinedPasses = false) {}
    public function define(string $ability, Closure $policy): void { $this->policies[$ability] = $policy; }
    public function allows(string $ability, User $user, object $target): bool {
        if (!isset($this->policies[$ability])) return $this->undefinedPasses;
        return ($this->policies[$ability])($user, $target);
    }
}

final class PostPolicy {
    public static function edit(User $user, Post $post): bool {
        return $user->id === $post->ownerId;
    }
}

final class PostApp {
    public function __construct(private IdentityStore $identity, private Gate $gate, public array $posts) {}
    public function edit(Request $request, int $postId, string $ability = 'post.edit'): array {
        Ledger::newRequest();
        foreach (['identity', 'gate', 'edit'] as $part) Ledger::onPath($part);
        Ledger::ran('identity');
        $user = $request->session === null ? null : $this->identity->find($request->session);
        if ($user === null) return [302, '/login'];
        Ledger::ran('gate');
        $post = $this->posts[$postId];
        if (!$this->gate->allows($ability, $user, $post)) return [403, 'not allowed'];
        Ledger::ran('edit');
        $post->title = $request->body['title'];
        return [200, 'updated'];
    }
}
```

```php
<?php
// gate.php — two users, one post, three requests
require_once 'authorization.php';

$identity = new IdentityStore();
$identity->register(1, 'alice', 'password-alice');
$identity->register(2, 'bob', 'password-bob');
echo 'login with correct password: ', $identity->login('bob', 'password-bob')?->name ?? 'none', "\n";
echo 'login with wrong password: ', $identity->login('bob', 'password-alice')?->name ?? 'none', "\n";

$gate = new Gate();
$gate->define('post.edit', PostPolicy::edit(...));
$app = new PostApp($identity, $gate, [10 => new Post(10, 1, 'Hello')]);

function measure(string $name, array $response): void {
    printf("%s -> %d %s\n", $name, $response[0], $response[1]);
    foreach (['identity', 'gate', 'edit'] as $part) printf("  %-8s %s\n", $part, classify($part));
}

measure('no session', $app->edit(new Request(null, ['title' => 'New']), 10));
measure('bob', $app->edit(new Request(2, ['title' => 'New']), 10));
measure('alice', $app->edit(new Request(1, ['title' => 'New']), 10));
echo 'title: ', $app->posts[10]->title, "\n";
```

```
login with correct password: bob
login with wrong password: none
no session -> 302 /login
  identity ran
  gate     skipped
  edit     skipped
bob -> 403 not allowed
  identity ran
  gate     ran
  edit     skipped
alice -> 200 updated
  identity ran
  gate     ran
  edit     ran
title: New
```

The first lines are the `password_verify()` results: the correct password returns the user, and another person's password does not. The real observation is in the request lines. In the request without a session, `identity` ran, the gate and the action were on the path but were not called: both were **skipped**, and the status code is 302. The second request is this lesson's observation: Bob is logged in, authenticated, a legitimate user, and he wants to edit Alice's post. `identity` and `gate` ran, the gate executed the policy, the policy returned false, the status became 403, and `edit` was **skipped**. That the title on the last line is `New` and not `Hello` says the change came only from the third request.

A skipped part gives itself away through the status code. Seeing 403 in Bob's request explains why the action did not run; no error was thrown, because a refusal is an expected outcome.

#### The Contrast: The Post's Owner

The third request goes through the same code path, and the only difference is that the user is the owner of the post. The policy returns true, the gate lets the request through, `edit` **ran**, and the status is 200. Nothing changed in the gate; what changed is that the user given to the policy matches the `ownerId` property of the post. The gate is not an obstacle but a decision, and the decision is made again in every request.

A distinction is needed here. `identity` ran in all three requests: whether authentication succeeded or failed, the step was called. The gate being skipped in the request without a session and the action being skipped in Bob's request have different causes, but the outcome is the same: the path existed, and a step before the part gave the response early. It is `classify()`, not the cause, that determines the outcome.

#### The Bounding Measurement

What happens if the gate is asked about an ability that was never defined? In real applications this happens through a typo: the policy is bound to the name `post.edit`, and the action asks for `post.edti`. The model builds two gates: one denies the undefined ability (the `Gate` default), the other lets it pass (`undefinedPasses: true`). The same misspelled ability name is asked for both the owner of the post and a user who is not the owner.

```php
<?php
// undefined.php — the gate is asked about an undefined ability
require_once 'authorization.php';

function build(bool $undefinedPasses): PostApp {
    $identity = new IdentityStore();
    $identity->register(1, 'alice', 'password-alice');
    $identity->register(2, 'bob', 'password-bob');
    $gate = new Gate($undefinedPasses);
    $gate->define('post.edit', PostPolicy::edit(...));
    return new PostApp($identity, $gate, [10 => new Post(10, 1, 'Hello')]);
}

foreach ([false, true] as $passes) {
    foreach ([[1, 'alice'], [2, 'bob']] as [$session, $name]) {
        $app = build($passes);
        $response = $app->edit(new Request($session, ['title' => 'New']), 10, 'post.edti');
        printf("%-10s %-6s -> %d  gate %-8s edit %s\n", $passes ? 'passing' : 'rejecting', $name,
            $response[0], classify('gate'), classify('edit'));
    }
}
```

```
rejecting  alice  -> 403  gate ran      edit skipped
rejecting  bob    -> 403  gate ran      edit skipped
passing    alice  -> 200  gate ran      edit ran
passing    bob    -> 200  gate ran      edit ran
```

The result has two sides. With the rejecting gate, even the owner of the post gets 403: the failure is visible loudly, and the developer notices the name mismatch on the first attempt. With the passing gate, Bob's request returns 200 and the action runs; the gate ran, but no policy executed, so treating the presence of a gate as the same thing as protection is misleading. The principle **an undefined ability must be denied** follows from this: answering "yes" to an unknown question means the protection is silently lifted, and the response leaves no trace of it.

#### Summary

- This lesson's outcome is **skipped**: in Bob's request `identity` and `gate` ran, `edit` was on the path, but the gate returned 403 and the action was not called.
- Authentication establishes who someone is, authorization establishes what that person may do; the first shows itself with 302, the second with 403.
- Its contrast: for the post's owner the policy returned true, `edit` **ran**, and the status was 200.
- In the bounding measurement, with the rejecting gate both users received 403 for the undefined ability; with the passing gate both received 200 and the action ran. An undefined ability must be denied.
- A password is stored with `password_hash()` and verified with `password_verify()`; the hash is not written to the output.

#### Next Step

When the gate refused, the action was skipped and the response returned at once. But if the action will run and a job inside it will delay the response, does that job have to make the response wait? The Queues and Jobs lesson leaves a long job outside the request and measures that the response does not wait for it.

### Queues and Jobs

Source: https://academia.sh/en/courses/web-application-framework/queues-and-jobs

The job class, pushing onto a queue and the worker; a job pushed onto the queue being deferred at response time while the same job runs on the sync driver; a job that fails on its first attempt being retried and falling into the failed jobs list when its attempts run out.

In the Authentication and Authorization lesson the gate skipped the action and the response returned at once. When the gate lets the request through, the action runs, and inside the action there may be a job that delays the response: producing the thumbnail of an image, preparing a report. This lesson asks: **should this job make the response wait, and if not, who runs it and when?**

First, naming. In this lesson the model is **in the same process**: there is no separate server, separate process or network. The "queue" is an array, and the **worker** is the name of the `process()` call that takes jobs from this array and runs them. The call is made after the response of the request has returned. In a real system the worker would be a separate process; here the distinction is only whether the response waits for the job. A similar question appeared in the JVM, Concurrency and Performance course (M08/K04), but there the search was for who carries out the step: which thread ran it was measured. The question here is different: whether the response waits for the job.

**AS2.** The queue is an array, the worker is a synchronous loop. Timeouts, concurrent workers, locking and the durability of job order are not modeled; no queue driver name is used. Each attempt is tracked by a counter and `sleep` is never called: the retry interval is the attempt number, not real time. An exception thrown inside a job is caught in the worker and never reaches the response.

#### The Measurement Core

The same measurement is used in every lesson of the course. `ran.php` records whether an expected part ran in this request; the file is given verbatim in every lesson, and it has no output block, since it is only saved to disk. `Ledger` keeps three lists: the parts placed on the request's path, the parts called within the request, and the parts left outside the request. `classify()` checks them in order. **The decision order is `classify()` itself:** ran, deferred, skipped, not wired. The developer names the part; what is measured are the parts the developer expects to run in this request.

```php
<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}
```

This lesson uses all three calls of the core. The thumbnail job is placed on the path (`Ledger::onPath()`), marked as left outside the request with `Ledger::defer()` when it is sent to the queue, and marked as called with `Ledger::ran()` on the sync driver.

#### The Job Class, the Driver and the Worker

`jobs.php` is the whole slice. `BaseJob` is the contract of a job: a `name()`, a `run()` and an `attempt` property that counts how many times the job has been tried. `ThumbnailJob` takes a single post id, and when `run()` is called it adds the id to an array; the id being written to the array is the proof that the job ran.

The **driver** decides how a job is sent. The `Driver` interface has a single method, `send()`. `SyncDriver` does not put the job on a queue; it marks it with `Ledger::ran()` and runs it at once. `MemoryDriver` marks it with `Ledger::defer()`, adds the job to an array and returns the response; the job waits there. `process()` runs the waiting jobs: it pulls each job from the array, increments the attempt, calls `run()`, and if it catches an exception it puts the job back at the end of the array when attempts remain, or moves it to the `failed` list when none remain.

`PostsController::save()` does not know the driver. It talks only to the `Driver` interface: it creates a job object, calls `send()` and returns 201. The controller's code is the same on both drivers; what changes is what the driver does with the job.

```php
<?php
// jobs.php — the job class, two drivers and the worker
declare(strict_types=1);
require_once 'ran.php';

abstract class BaseJob {
    public int $attempt = 0;
    abstract public function name(): string;
    abstract public function run(): void;
}

final class ThumbnailJob extends BaseJob {
    public static array $produced = [];
    public function __construct(private int $postId) {}
    public function name(): string { return 'thumbnail'; }
    public function run(): void { self::$produced[] = $this->postId; }
}

interface Driver {
    public function send(BaseJob $job): void;
}

final class SyncDriver implements Driver {
    public function send(BaseJob $job): void {
        Ledger::ran($job->name());
        $job->run();
    }
}

final class MemoryDriver implements Driver {
    private array $pending = [];
    public array $failed = [];
    public function __construct(private int $maxAttempts = 3) {}
    public function send(BaseJob $job): void {
        Ledger::defer($job->name());
        $this->pending[] = $job;
    }
    public function pending(): int { return count($this->pending); }
    public function process(): array {         // worker: runs independently of the request
        $log = [];
        while ($job = array_shift($this->pending)) {
            $job->attempt++;
            try {
                $job->run();
                $log[] = "{$job->name()} attempt {$job->attempt}: ran";
            } catch (Throwable $error) {
                $log[] = "{$job->name()} attempt {$job->attempt}: " . get_class($error);
                if ($job->attempt < $this->maxAttempts) $this->pending[] = $job;
                else $this->failed[] = $job;
            }
        }
        return $log;
    }
}

final class PostsController {
    public function __construct(private Driver $driver) {}
    public function save(int $postId): array {
        Ledger::newRequest();
        Ledger::onPath('save');
        Ledger::onPath('thumbnail');
        Ledger::ran('save');
        $this->driver->send(new ThumbnailJob($postId));
        return [201, 'saved'];
    }
}
```

#### A Job Pushed onto the Queue

The first measurement asks about two parts: `save` (the controller's own step) and `thumbnail` (the job pushed onto the queue). The driver is the queue. After the response returns, `process()` is called and the worker runs. The contrasting measurement runs the same controller with the sync driver.

```php
<?php
// queue.php — the same job with two drivers
require_once 'jobs.php';

function measure(string $name, Driver $driver): void {
    ThumbnailJob::$produced = [];
    $response = (new PostsController($driver))->save(7);
    printf("%s -> %d %s\n", $name, $response[0], $response[1]);
    foreach (['save', 'thumbnail'] as $part) printf("  %-11s %s\n", $part, classify($part));
    echo '  on path: ', in_array('thumbnail', Ledger::$path, true) ? 'yes' : 'no', "\n";
    echo '  produced at response time: ', count(ThumbnailJob::$produced), "\n";
}

$queue = new MemoryDriver();
measure('queue', $queue);
echo '  pending jobs: ', $queue->pending(), "\n";
foreach ($queue->process() as $line) echo '  worker: ', $line, "\n";
echo '  produced after worker: ', count(ThumbnailJob::$produced), "\n";

measure('sync', new SyncDriver());
```

```
queue -> 201 saved
  save        ran
  thumbnail   deferred
  on path: yes
  produced at response time: 0
  pending jobs: 1
  worker: thumbnail attempt 1: ran
  produced after worker: 1
sync -> 201 saved
  save        ran
  thumbnail   ran
  on path: yes
  produced at response time: 1
```

This lesson's observation is in the first block. The response returned 201, but `thumbnail` was **deferred**: it had been placed on the path, it was not called within the request, and it was left outside the request. The "produced at response time" count is 0, so the thumbnail does not exist yet; the pending job count is 1. When the worker ran through `process()`, the job **ran on the first attempt** and the produced count became 1. The client that received the response did not wait for the thumbnail.

The decision order is useful here. Because `thumbnail` is on the path, the `on path` line says `yes`; that line alone does not say the job ran. `classify()` first looks at the `ran` list and does not find it there; then it looks at the `deferred` list and finds it. Being "on the path" and "running" are separate observations, and a deferred part sits exactly between them.

#### The Contrast: The Sync Driver

The second block runs the same controller with `SyncDriver`. Same job, same `send()` call; this time `thumbnail` **ran** and the count produced at response time is 1. The client receives its response after the job has finished. The code is the same, and the only thing that changed is the driver. This shows that the queue does not make the job smaller; it only moves the moment the job runs outside the request.

To put the distinction in one sentence: in M08/K04 we looked for who carries out the step; here what matters is not who carries it out but whether the response waits for it. On the sync driver the response waits, on the queue it does not.

#### The Bounding Measurement

Deferring the job through the queue also has a cost: because the job runs after the response, an error does not reach the client and stays in the worker. This is why retries and the failed jobs list are needed. The measurement below builds two jobs. The first throws an exception only on its first attempt; the second throws on its first five attempts. The attempt allowance is 3.

```php
<?php
// retry.php — a job that fails on its first attempt
require_once 'jobs.php';

final class FlakyJob extends BaseJob {
    public function __construct(private int $failingAttempts) {}
    public function name(): string { return 'flaky'; }
    public function run(): void {
        if ($this->attempt <= $this->failingAttempts) throw new RuntimeException('temporary error');
    }
}

foreach ([[1, 3], [5, 3]] as [$failing, $allowance]) {
    Ledger::newRequest();
    $queue = new MemoryDriver($allowance);
    $queue->send(new FlakyJob($failing));
    echo "failing attempts $failing, allowance $allowance\n";
    echo '  in request: ', classify('flaky'), "\n";
    foreach ($queue->process() as $line) echo '  worker: ', $line, "\n";
    echo '  failed jobs: ', count($queue->failed), "\n";
}
```

```
failing attempts 1, allowance 3
  in request: deferred
  worker: flaky attempt 1: RuntimeException
  worker: flaky attempt 2: ran
  failed jobs: 0
failing attempts 5, allowance 3
  in request: deferred
  worker: flaky attempt 1: RuntimeException
  worker: flaky attempt 2: RuntimeException
  worker: flaky attempt 3: RuntimeException
  failed jobs: 1
```

Both jobs were **deferred** in the request: the response never saw the error in either case. The first job threw `RuntimeException` on its first attempt, the worker caught it, put the job back at the end of the array because its allowance was not used up, and on the second attempt the job **ran**. The failed jobs list stayed empty. The second job failed on all three attempts. Because no allowance remained after the third attempt, it was not put back and fell into the `failed` list: there is 1 job in the list. The fifth attempt was never made.

The numbers say this: retrying is for temporary errors and does not try forever if the error persists; there is a limited allowance, and when it runs out the job falls into a visible place, the failed jobs list. If that list is not empty, someone needs to look at it. A response returning 201 says only that the post was saved; it does not show that the thumbnail job succeeded, because that job has only been pushed onto the queue.

#### Summary

- This lesson's outcome is **deferred**: the controller pushed the thumbnail job onto the queue, the response returned 201, the job was not called at response time, and it ran in the worker.
- The model is in the same process; the worker is the name of the `process()` call and runs after the response.
- Its contrast: on the sync driver the same job **ran** and the response waited for it; the controller code did not change, only the driver did.
- In the bounding measurement, the job that failed on its first attempt ran on the second attempt; the job that failed on all three attempts fell into the failed jobs list.
- Having been pushed onto the queue is not the same as having succeeded: 201 says the post was saved, and it does not say what became of the job in the queue.

#### Next Step

The thumbnail job went onto the queue, but the controller knew it by name: `new ThumbnailJob(...)`. When a new side effect is needed, for example updating a search index when a post is saved, a new line has to be added to the controller. The Events and Listeners lesson loosens this tie: the controller dispatches an event, and a listener handles it. But what happens if the event name does not match the name the listener is registered under?

### Events and Listeners

Source: https://academia.sh/en/courses/web-application-framework/events-and-listeners

The event, the listener and the dispatcher; separating side effects from the controller; a listener not being wired when the registered name does not match the dispatched name, a queued listener being deferred, and giving the event name as a class name catching the mismatch at registration.

In the Queues and Jobs lesson the controller pushed the thumbnail job onto the queue, but it knew the job's class by name: `new ThumbnailJob(...)`. If saving a post also has to update a search index, increment a counter and write a log line, the controller grows with every new side effect. This lesson asks: **if the controller announces what happened without knowing who will be interested, are the interested parties still called?**

An **event** is the name of something that has happened, together with the data it carries: the `PostSaved` event carries the id of the post. A **listener** is a function bound to an event name and called when the event is dispatched. The **dispatcher** holds the listeners bound to names and, when `dispatch()` is called, calls the ones that match. The controller only dispatches the event; the index update leaves the controller and goes to the listener. The tie between them is **loose**: the controller does not know the listener exists, and the listener does not know the controller. This looseness has a cost, and this lesson's measurement shows it: if the names do not match, nobody reports an error.

**AS3.** The event name and the name a listener is registered under are matched by string comparison; the match is exact equality, with no prefix or wildcard. If no listener is found while an event is dispatched, no error is thrown: an event with no listeners is legitimate. A queued listener is in the same process, as in the Queues and Jobs lesson; `process()` runs the waiting ones in the dispatcher. The listener order is the registration order; stopping an event and priorities are not modeled.

#### The Measurement Core

The same measurement is used in every lesson of the course. `ran.php` records whether an expected part ran in this request; the file is given verbatim in every lesson, and it has no output block, since it is only saved to disk. `Ledger` keeps three lists: the parts placed on the request's path, the parts called within the request, and the parts left outside the request. `classify()` checks them in order. **The decision order is `classify()` itself:** ran, deferred, skipped, not wired. The developer names the part; what is measured are the parts the developer expects to run in this request.

```php
<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}
```

This lesson uses all four calls of the core, and for the listener the order matters: when the dispatcher dispatches an event, it places the listeners registered under that name on the path, marks a synchronous one with `Ledger::ran()` and calls it, and marks a queued one with `Ledger::defer()` and leaves it on the queue. If no listener is placed on the path, the name is in none of the lists and `classify()` says **not wired**.

#### The Event, the Dispatcher and the Listener

`events.php` is the whole slice. `PostSaved` is an immutable event that carries the post id. `Index` is a reduced form of the search index: only a list of the ids that were added. `Dispatcher` does three jobs. `listen()` adds a listener to an event name; `dispatch()` calls the listeners registered under that name in order, or puts them on the queue; `process()` runs the ones on the queue and returns how many it ran. `listenClass()` is a guarded form of `listen()`: if the class given as the event name does not exist, it throws `InvalidArgumentException` at registration. The last section shows why this is needed.

`PostsController::save()` saves the post (in this lesson, saving means the `save` part runs), dispatches the event and returns 201. The name under which it dispatches is supplied from outside; this makes it possible to mismatch the name on purpose in the next measurement.

```php
<?php
// events.php — the event, the dispatcher and the listener
declare(strict_types=1);
require_once 'ran.php';

final class PostSaved {
    public function __construct(public readonly int $postId) {}
}

final class Index {
    public static array $entries = [];
}

final class Dispatcher {
    private array $listeners = [];   // event name => [[name, function, queued], ...]
    private array $queue = [];

    public function listen(string $event, string $name, callable $callback, bool $queued = false): void {
        $this->listeners[$event][] = [$name, $callback, $queued];
    }

    public function listenClass(string $class, string $name, callable $callback, bool $queued = false): void {
        if (!class_exists($class)) {
            throw new InvalidArgumentException("event class not found: $class");
        }
        $this->listen($class, $name, $callback, $queued);
    }

    public function dispatch(string $event, object $payload): void {
        foreach ($this->listeners[$event] ?? [] as [$name, $callback, $queued]) {
            Ledger::onPath($name);
            if ($queued) {
                Ledger::defer($name);
                $this->queue[] = [$callback, $payload];
            } else {
                Ledger::ran($name);
                $callback($payload);
            }
        }
    }

    public function process(): int {           // worker: runs the waiting listeners
        $count = count($this->queue);
        while ($entry = array_shift($this->queue)) { [$callback, $payload] = $entry; $callback($payload); }
        return $count;
    }
}

final class PostsController {
    public function __construct(private Dispatcher $events, private string $eventName) {}
    public function save(int $postId): array {
        Ledger::newRequest();
        Ledger::onPath('save');
        Ledger::ran('save');
        $this->events->dispatch($this->eventName, new PostSaved($postId));
        return [201, 'saved'];
    }
}

function addToIndex(PostSaved $event): void { Index::$entries[] = $event->postId; }
```

#### The Listener Is Not Wired

The first measurement sets up two registrations. In both there is an `index` listener, and the controller dispatches an event named `post.saved`. In the first the listener is registered under `post.created`; in the second under `post.saved`.

```php
<?php
// wire.php — the registered name and the dispatched name do not match
require_once 'events.php';

function measure(string $registeredName, string $dispatchedName): void {
    Index::$entries = [];
    $events = new Dispatcher();
    $events->listen($registeredName, 'index', 'addToIndex');
    $response = (new PostsController($events, $dispatchedName))->save(7);
    printf("registered %s, dispatched %s -> %d\n", $registeredName, $dispatchedName, $response[0]);
    foreach (['save', 'index'] as $part) printf("  %-7s %s\n", $part, classify($part));
    echo '  added to index: ', count(Index::$entries), "\n";
}

measure('post.created', 'post.saved');
measure('post.saved', 'post.saved');
```

```
registered post.created, dispatched post.saved -> 201
  save    ran
  index   not wired
  added to index: 0
registered post.saved, dispatched post.saved -> 201
  save    ran
  index   ran
  added to index: 1
```

This lesson's observation is in the first registration. The response is 201, `save` ran and `index` was **not wired**: the listener did not run, nothing was added to the index, and yet no error appeared. The listener was registered with the dispatcher and was sound as a function; it was just not bound to the dispatched name. `classify()` looked at the `ran` list, then the `deferred` list, then the `path` list, and found the listener in none of them: because the dispatcher found no listener under that name, it never placed `index` on the path. This is where skipped and not wired separate. A skipped part was on the path and was not called; a part that is not wired never entered the path.

The second registration is the contrasting measurement. When the names match, the same listener **ran** and one entry was added to the index. The code is the same; only the equality of two strings changed. This is a direct measurement of the cost of a loose tie: neither the compiler nor the runtime counts a string mismatch as an error.

#### The Contrast: Queued and Synchronous Listeners

Whether a listener makes the response wait is a separate question. The driver distinction from the Queues and Jobs lesson comes down here to a flag on the listener: `queued`. The same listener is registered this time with matching names and measured in two forms.

```php
<?php
// queued.php — the same listener, synchronous and queued
require_once 'events.php';

foreach ([false, true] as $queued) {
    Index::$entries = [];
    $events = new Dispatcher();
    $events->listen('post.saved', 'index', 'addToIndex', $queued);
    $response = (new PostsController($events, 'post.saved'))->save(7);
    echo $queued ? "queued listener" : "synchronous listener", " -> {$response[0]}\n";
    echo '  index ', classify('index'), ', added at response time: ', count(Index::$entries), "\n";
    echo '  worker ran: ', $events->process(), ', added after: ', count(Index::$entries), "\n";
}
```

```
synchronous listener -> 201
  index ran, added at response time: 1
  worker ran: 0, added after: 1
queued listener -> 201
  index deferred, added at response time: 0
  worker ran: 1, added after: 1
```

For the synchronous listener `index` **ran**, there is one entry in the index at response time, and the worker has nothing left to do: `process()` ran zero jobs. For the queued listener `index` was **deferred**: the index is empty at response time, `process()` runs one job, and only then does the entry appear. The numbers are consistent in both directions: in both cases there is one entry in the index at the end, and the difference is in when the entry appears.

#### The Bounding Measurement

The mismatch stayed silent in the first measurement because the name is a string. If a **class name** is given as the event name, can this risk be caught at registration? The measurement below tries two registrations with `listenClass()`: the correctly written `PostSaved` and `PostSavef`, which has one letter wrong. It then registers the same wrong name as a string with `listen()` and dispatches the correct name.

```php
<?php
// class.php — the event name as a string or as a class name
require_once 'events.php';

foreach (['PostSaved', 'PostSavef'] as $name) {
    $events = new Dispatcher();
    try {
        $events->listenClass($name, 'index', 'addToIndex');
        echo "$name: registration passed\n";
    } catch (InvalidArgumentException $error) {
        echo "$name: registration rejected (", $error->getMessage(), ")\n";
    }
}

Ledger::newRequest();
$events = new Dispatcher();
$events->listen('PostSavef', 'index', 'addToIndex');   // string: accepted silently
$events->dispatch('PostSaved', new PostSaved(7));
echo 'registration by string name, index in request: ', classify('index'), "\n";
```

```
PostSaved: registration passed
PostSavef: registration rejected (event class not found: PostSavef)
registration by string name, index in request: not wired
```

Registering by class name rejected the misspelled name **at registration**: `listenClass()` checked whether the class exists and threw an exception because `PostSavef` does not. This registration happens before the response cycle, while the application is being set up; the developer sees the mistake on the first run, not on the first user request. When the same wrong name was given as a string, the registration was accepted silently, and in the event dispatched under the correct name, `index` was **not wired**: the same situation as in the first measurement.

There is a limit. A class name catches a spelling mistake; it does not catch choosing a wrong class that does exist. The protection also comes from the check that `listenClass()` performs: a class name is also a string, and if no code was written to check it, it is no different from a string. Giving the event name as a class name instead of a string moves the mismatch from run time to registration time, and this gain depends on the check having been built.

#### Summary

- This lesson's outcome is **not wired**: the listener was registered under `post.created`, the event was dispatched as `post.saved`, `index` was not called, and the response still returned 201.
- The dispatcher calls the listeners bound to names; the controller does not know the number or the names of the side effects, but the cost of this is a silent mismatch.
- Its contrast: when the name matches, the listener **ran**; when marked as queued, it was **deferred** and the entry appeared only after `process()`.
- In the bounding measurement, registering the event name as a class name rejected the misspelled name at registration; the string name accepted the same mistake silently.
- A skipped part was on the path and was not called; a part that is not wired never entered the path.

#### Next Step

When the post was saved, the index was updated, but telling the user is a separate side effect: an email, an in-app alert, a text message? The Notifications lesson answers a single event in more than one way and measures which notification channel the recipient chooses.

### Notifications

Source: https://academia.sh/en/courses/web-application-framework/notifications

The notification class, the notification channel and the recipient's channel preference; the chosen synchronous channels running and the unchosen channel not being wired; the same channels being deferred when the notification is marked as queued.

In the Events and Listeners lesson the index was updated when a post was saved; telling the user is a separate side effect. There can be more than one way to tell, and not everyone wants the same one. This lesson asks: **by which routes does a notification go, and is a route the recipient did not choose called?**

The **notification** class defines what is said and through which **notification channels** it may go. A **notification channel** is the part that delivers the notification somewhere: mail, database and SMS are the three channels in this lesson. The **recipient's channel preference** says which of them will be used. The notification class lists all three channels; the recipient chooses one or two. The notifier places the intersection of the two on the path. A channel outside the intersection is neither called nor placed on the path.

**AS4.** All three channels are array records: the mail channel does not connect to a mail server and the SMS channel does not connect to a carrier; each channel appends the sent text to its own array, and what is measured is whether there is a record in that array. As in the earlier lessons, the model is in the same process. Failed delivery, resending and throttling are not modeled. Whether a notification is queued is a field of the notification class; the notifier looks at that field.

#### The Measurement Core

The same measurement is used in every lesson of the course. `ran.php` records whether an expected part ran in this request; the file is given verbatim in every lesson, and it has no output block, since it is only saved to disk. `Ledger` keeps three lists: the parts placed on the request's path, the parts called within the request, and the parts left outside the request. `classify()` checks them in order. **The decision order is `classify()` itself:** ran, deferred, skipped, not wired. The developer names the part; what is measured are the parts the developer expects to run in this request.

```php
<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}
```

In this lesson each channel is a part: `mail`, `database` and `sms`. The notifier places the channel the recipient chose on the path and, if the notification is synchronous, calls it and marks it with `Ledger::ran()`; if it is queued, it leaves it on the queue and marks it with `Ledger::defer()`. No call is made for a channel that was not chosen.

#### The Notification, the Channel and the Recipient

`notification.php` is the whole slice. `Recipient` carries a name and channel preferences. `CommentNotification` carries a comment title, the names of the three channels (`channels()`) and the `queued` flag. The `NotificationChannel` interface has two methods, `name()` and `send()`; `ArrayChannel` implements it with an array of records. `Notifier::send()` walks the notification's channels; it skips the ones that are not in the recipient's preferences (`continue`), places the others on the path, and either calls them or leaves them on the queue. `process()` runs what is on the queue. `CommentController::postComment()` saves the comment and hands it to the notifier.

```php
<?php
// notification.php — the notification class, the channels and the recipient preference
declare(strict_types=1);
require_once 'ran.php';

final class Recipient {
    public function __construct(public readonly string $name, public readonly array $preferences) {}
}

final class CommentNotification {
    public function __construct(public readonly string $title, public readonly bool $queued = false) {}
    public function channels(): array { return ['mail', 'database', 'sms']; }
    public function text(): string { return "New comment: {$this->title}"; }
}

interface NotificationChannel {
    public function name(): string;
    public function send(Recipient $recipient, CommentNotification $notification): void;
}

final class ArrayChannel implements NotificationChannel {   // every channel is an array of records
    public array $records = [];
    public function __construct(private string $name) {}
    public function name(): string { return $this->name; }
    public function send(Recipient $recipient, CommentNotification $notification): void {
        $this->records[] = $recipient->name . ': ' . $notification->text();
    }
}

final class Notifier {
    private array $channels = [];
    private array $queue = [];
    public function addChannel(NotificationChannel $channel): void { $this->channels[$channel->name()] = $channel; }
    public function channel(string $name): ArrayChannel { return $this->channels[$name]; }

    public function send(Recipient $recipient, CommentNotification $notification): void {
        foreach ($notification->channels() as $name) {
            if (!in_array($name, $recipient->preferences, true)) continue;   // the recipient did not choose it
            Ledger::onPath($name);
            if ($notification->queued) {
                Ledger::defer($name);
                $this->queue[] = [$this->channels[$name], $recipient, $notification];
            } else {
                Ledger::ran($name);
                $this->channels[$name]->send($recipient, $notification);
            }
        }
    }

    public function process(): int {            // worker: runs the queued notifications
        $count = count($this->queue);
        while ($entry = array_shift($this->queue)) { [$channel, $recipient, $notification] = $entry; $channel->send($recipient, $notification); }
        return $count;
    }
}

final class CommentController {
    public function __construct(private Notifier $notifier) {}
    public function postComment(Recipient $recipient, CommentNotification $notification): array {
        Ledger::newRequest();
        Ledger::onPath('postComment');
        Ledger::ran('postComment');
        $this->notifier->send($recipient, $notification);
        return [201, 'comment posted'];
    }
}

function buildNotifier(): Notifier {
    $notifier = new Notifier();
    foreach (['mail', 'database', 'sms'] as $name) $notifier->addChannel(new ArrayChannel($name));
    return $notifier;
}
```

#### The Channel the Recipient Chose

The first measurement sets up two recipients. Alice has chosen mail and database; Bob has chosen only SMS. The same comment notification is sent to both.

```php
<?php
// preferences.php — two recipients, two different channel preferences
require_once 'notification.php';

$recipients = [
    new Recipient('alice', ['mail', 'database']),
    new Recipient('bob', ['sms']),
];

foreach ($recipients as $recipient) {
    $notifier = buildNotifier();
    $response = (new CommentController($notifier))->postComment($recipient, new CommentNotification('Hello'));
    echo "{$recipient->name} (", implode(', ', $recipient->preferences), ") -> {$response[0]}\n";
    foreach (['mail', 'database', 'sms'] as $name) {
        printf("  %-11s records: %d  %s\n", $name, count($notifier->channel($name)->records), classify($name));
    }
}
```

```
alice (mail, database) -> 201
  mail        records: 1  ran
  database    records: 1  ran
  sms         records: 0  not wired
bob (sms) -> 201
  mail        records: 0  not wired
  database    records: 0  not wired
  sms         records: 1  ran
```

This lesson's observation is in the first recipient: both of the chosen notification channels are synchronous, so they **ran**, and each has one record. The response is 201. The `sms` channel that Alice did not choose was **not wired**: the notification class listed that channel, but the notifier never placed it on the path, because it was not in the recipient's preference. The record count of 0 does not say that the SMS failed; it says that it was never tried.

The second recipient is the contrast and shows the same rule from the other side. Bob chose only `sms`; `sms` **ran**, and mail and database were **not wired**. The same notification class, the same notifier, the same controller; only the recipient's preference changed. Note that a channel that was not chosen is not skipped: a skipped part is one that entered the path and was not called, but a channel that was not chosen was never placed on the path.

#### The Bounding Measurement

What happens to the same channels if the notification is marked as queued? The same comment notification is sent to Alice, but this time with `queued: true`. The measurement counts the channels right after the response and again after the worker has run.

```php
<?php
// queued_notification.php — when the notification is marked as queued
require_once 'notification.php';

$recipient = new Recipient('alice', ['mail', 'database']);
$notifier = buildNotifier();
$response = (new CommentController($notifier))->postComment($recipient, new CommentNotification('Hello', queued: true));
echo "queued notification -> {$response[0]}\n";
foreach (['mail', 'database', 'sms'] as $name) {
    printf("  %-11s records: %d  %s\n", $name, count($notifier->channel($name)->records), classify($name));
}
echo '  worker ran: ', $notifier->process(), "\n";
foreach (['mail', 'database', 'sms'] as $name) {
    printf("  %-11s records: %d\n", $name, count($notifier->channel($name)->records));
}
```

```
queued notification -> 201
  mail        records: 0  deferred
  database    records: 0  deferred
  sms         records: 0  not wired
  worker ran: 2
  mail        records: 1
  database    records: 1
  sms         records: 0
```

The response is the same, 201. The two chosen channels were now **deferred**: they were placed on the path, left outside the request, and their record counts at response time are 0. The channel that was not chosen is still **not wired**. `process()` ran two jobs, and afterward mail and database have one record each; SMS still has 0 records, because no choice required it to enter the queue. Marking the notification as queued did not change the preference rule; it only moved the moment at which the chosen channels run to after the response.

This is the distinction from the Queues and Jobs lesson seen once more: being put on the queue means that the record exists only when the worker has run. An application that believes the notification has gone should look at the record after the worker, not at the moment it sees the response.

#### Summary

- This lesson's outcome is **ran**: the mail and database channels that Alice chose were synchronous, were called within the response and each left a record.
- The notification class lists the channels, the recipient's preference chooses, and the notifier places the intersection of the two on the path.
- Its contrast: the `sms` channel that Alice did not choose, and the mail and database channels that Bob did not choose, were **not wired**; the record count is 0 and the channel was never tried.
- In the bounding measurement, when the notification was marked as queued the chosen channels were **deferred**, `process()` ran two jobs, and the records appeared only afterward.
- A channel that was not chosen is not skipped: a skipped part enters the path, and a channel that was not chosen does not.

#### Next Step

The notification channels have to repeat the same query on every request: the recipient's preference, a comment count, the ordering of a list. Computing the same result again has a cost, and sometimes skipping it is enough. The Caching lesson stores a computed value under a key and measures whether the computation is skipped on a hit.

### Caching

Source: https://academia.sh/en/courses/web-application-framework/caching

The key-based cache, the time to live, the remember function and invalidation; the computation being skipped on a hit and running after invalidation; recomputation when the time to live ends, and the current record not reaching the response when invalidation is forgotten.

In the Notifications lesson the notification channels ran again on every request; every request had to ask the same question from the beginning. If computing a value is expensive and the value does not change often, it is cheaper to store the result and skip the computation on the second request. This lesson asks: **when the record changes, does the stored value change too?**

A **cache** is a place that stores a value under a **key**. The **time to live** says how long the stored value stays valid; when the time ends, the value is stale and is computed again. The function at the center of the lesson is `remember(key, ttl, compute)`: if the key is valid in the cache, it returns the value and does not call the `compute` function (a **hit**); if not, it calls it, stores the result and returns it. Deleting the stored value when the record changes is called **invalidation**. A cache gives speed, but it also brings the risk of staleness, and the risk becomes concrete where invalidation is forgotten.

**AS5.** The cache is an array; it is not a server, a network or a file. The clock is a variable (`$now`); `sleep` is not called, and the passage of time is imitated by increasing this variable, so the measurement does not depend on real time. The time to live counts as ended exactly at the boundary: when the expiry moment arrives, the entry is no longer valid. Cache consistency, two simultaneous requests overwriting each other, and a size limit are not modeled.

#### The Measurement Core

The same measurement is used in every lesson of the course. `ran.php` records whether an expected part ran in this request; the file is given verbatim in every lesson, and it has no output block, since it is only saved to disk. `Ledger` keeps three lists: the parts placed on the request's path, the parts called within the request, and the parts left outside the request. `classify()` checks them in order. **The decision order is `classify()` itself:** ran, deferred, skipped, not wired. The developer names the part; what is measured are the parts the developer expects to run in this request.

```php
<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}
```

In this lesson the part that is measured is `compute`, the computation function given to the cache. `remember()` places it on the path on every call; if there is a hit, the function is not called and the part stays under **skipped**; if there is no hit, it is marked with `Ledger::ran()`. A second part, `current`, measures in the last section whether the response carries the current record in the store.

#### The Cache, the Time to Live and Invalidation

`cache.php` is the whole slice. `Store` is a reduced form of the persistent record: an array from post id to title, and it is the source of truth. `Cache` keeps one value and one expiry moment per key. `remember()` places the computation on the path and looks at the entry; if the expiry moment is greater than now, it returns the value; if not, it calls the function and stores the value with the expiry `now + ttl`. `forget()` deletes the entry, which is to say it invalidates. `PostsController::show()` gets the title through `remember()` and counts how many times it was really computed. `isCurrent()` is a measurement helper: if the title in the response is the same as the one in the store, it places the `current` part on the path and calls it; if not, it does nothing.

```php
<?php
// cache.php — the key-based cache, the time to live, invalidation
declare(strict_types=1);
require_once 'ran.php';

final class Store {                         // the persistent record: the source of truth
    public static array $title = [7 => 'Old title'];
}

final class Cache {
    public int $now = 0;                    // the clock is a variable; sleep is not called
    private array $entries = [];            // key => [value, expiry]

    public function remember(string $key, int $ttl, callable $compute): mixed {
        Ledger::onPath('compute');
        $entry = $this->entries[$key] ?? null;
        if ($entry !== null && $entry[1] > $this->now) {
            return $entry[0];               // hit: the computation is not called
        }
        Ledger::ran('compute');
        $value = $compute();
        $this->entries[$key] = [$value, $this->now + $ttl];
        return $value;
    }

    public function forget(string $key): void {   // invalidation
        unset($this->entries[$key]);
    }
}

final class PostsController {
    public static int $computations = 0;
    public function __construct(private Cache $cache) {}
    public function show(int $id): array {
        Ledger::newRequest();
        Ledger::onPath('show');
        Ledger::ran('show');
        $title = $this->cache->remember("post.$id", 60, function () use ($id) {
            self::$computations++;
            return Store::$title[$id];
        });
        return [200, $title];
    }
}

function isCurrent(array $response, int $id): void {   // does the response carry the current record in the store
    if ($response[1] === Store::$title[$id]) { Ledger::onPath('current'); Ledger::ran('current'); }
}
```

#### The Computation Was Skipped on a Hit

The first measurement makes three requests. In the first the cache is empty. The second comes ten units later and is still within the time. The third comes after the post's title has been changed in the store and the cache entry has been invalidated.

```php
<?php
// hit.php — the first request, a hit, invalidation
require_once 'cache.php';

$cache = new Cache();
$controller = new PostsController($cache);

function request(string $label, PostsController $c, Cache $k): void {
    $response = $c->show(7);
    printf("%s (time %d) -> %d %s\n", $label, $k->now, $response[0], $response[1]);
    echo '  compute: ', classify('compute'), ', total computations: ', PostsController::$computations, "\n";
}

request('first request', $controller, $cache);
$cache->now = 10;
request('second request', $controller, $cache);
Store::$title[7] = 'New title';
$cache->forget('post.7');
$cache->now = 20;
request('after invalidation', $controller, $cache);
```

```
first request (time 0) -> 200 Old title
  compute: ran, total computations: 1
second request (time 10) -> 200 Old title
  compute: skipped, total computations: 1
after invalidation (time 20) -> 200 New title
  compute: ran, total computations: 2
```

This lesson's observation is in the second request. The cache entry was valid, `remember()` returned the value, and `compute` was placed on the path but not called: **skipped**. The total number of computations stayed at 1. The response carried the same title, `Old title`; the client did not see the difference, but the work was not done. In the first request the same `compute` **ran**: the cache was empty, the computation was called, the result was stored and the total became 1.

The third request is the contrast. The title was changed in the store and `forget('post.7')` deleted the entry. The response carried `New title`, `compute` **ran** again and the total number of computations became 2. The same key, the same controller; only the presence of the entry decided whether the computation would run. Invalidation is the only way to tell the cache about a change.

#### The Bounding Measurement

What happens if invalidation is forgotten, and does the time to live correct it? The second measurement makes three requests. The first stores the value. Then the title changes in the store, but the entry is not deleted. The second request comes before the time ends. The third comes when the time has ended. In every request the `current` part measures whether the response carries the current record in the store.

```php
<?php
// stale.php — when the time to live ends and when invalidation is forgotten
require_once 'cache.php';

$cache = new Cache();
$controller = new PostsController($cache);

function measure(string $label, PostsController $c, Cache $k): void {
    $response = $c->show(7);
    isCurrent($response, 7);
    printf("%s (time %d) -> %s\n", $label, $k->now, $response[1]);
    echo '  compute: ', classify('compute'), ', current record: ', classify('current'), "\n";
}

measure('store in cache', $controller, $cache);
Store::$title[7] = 'New title';             // invalidation was forgotten
$cache->now = 30;
measure('before the time ends', $controller, $cache);
$cache->now = 60;
measure('after the time ends', $controller, $cache);
echo 'total computations: ', PostsController::$computations, "\n";
```

```
store in cache (time 0) -> Old title
  compute: ran, current record: ran
before the time ends (time 30) -> Old title
  compute: skipped, current record: not wired
after the time ends (time 60) -> New title
  compute: ran, current record: ran
total computations: 2
```

In the first request `compute` **ran**, and because the response carried the record in the store, `current` ran too. Then the title changed in the store and the entry was not deleted. In the second request, at time 30, the cache returned the valid entry: `compute` was **skipped** and the response carried `Old title`, which means the `New title` in the store did not reach the response. For this reason the `current` part never entered the path and is **not wired**. The response is 200 and there is no error; the stale value returns silently. This is the most dangerous form of a cache: the response looks successful, and what is missing is indicated nowhere.

The third request came at time 60, and the time to live had ended exactly. `compute` **ran** again, the response carried `New title` and `current` ran too. The total number of computations is 2. The time to live corrected the staleness, but only at the end of the time. The longer the time, the greater the cost of a forgotten invalidation. Invalidation and the time to live do not replace each other: invalidation reflects the change immediately, and the time to live is an upper bound against a forgotten invalidation.

#### Summary

- This lesson's outcome is **skipped**: in the second request the cache hit, `compute` was on the path and was not called, and the total number of computations stayed at 1.
- `remember(key, ttl, compute)` returns a valid entry, and if there is none it computes and stores; invalidation deletes the entry.
- Its contrast: after invalidation `compute` **ran** again, the response carried the new title and the total became 2.
- In the bounding measurement `compute` **ran** again when the time to live ended; when invalidation was forgotten, the current record was **not wired** to the response and the stale value returned with 200.
- The time to live does not replace invalidation; it only limits how long a forgotten invalidation stays stale.

#### Next Step

The value in the cache stayed stale as long as it did not know about the change at its source. What if the stored thing is not a value but a file, and the disk the file is written to changes: what changes, the upload code, the response, or the file itself? The File Storage lesson writes and reads a file on two separate disks and measures which of them changes when the disk changes.

### File Storage

Source: https://academia.sh/en/courses/web-application-framework/file-storage

The disk abstraction: the local disk and the in-memory disk behind the same interface; the same upload call running on two disks, a disk not named in the configuration not being wired, and a change of disk showing up in the life of the file and not in the response.

In the Caching lesson a stored value stayed stale as long as it was unaware of a change at its source. That lesson kept its values in an array; a file is a different thing from a value: it is large, it has to live outside the response, and where it is written may change during the life of the application. A developer writes the uploaded file to a working directory today and wants to move it tomorrow to a separate service known as object storage. This lesson asks: **when the place of storage changes, does the upload code change too?**

In this lesson a **disk** is the name of a place where a file can be written and read; it is not a product name. A **disk abstraction** means the controller knows only the `write`, `read` and `exists` contract and does not know where the file goes. An **interface** carries the contract, and each disk implements it. The lesson has two disks: a **local disk** that writes to a folder in the working directory, and an **in-memory disk** that keeps the files in an array. The in-memory disk is a model of real object storage: a file is stored under a path name, but there is no directory tree, permission or driver; there is only a name and content. A **configuration** says which disk will be used: names are mapped to disks and the default name is chosen separately.

**AS6.** The local disk is a folder opened for this lesson under the working directory; it is deleted when the lesson ends and the path is not printed in the output. The in-memory disk is an array and is lost when the process ends. Cloud storage, network latency, credentials, concurrent writes and file permissions are not modeled; the in-memory disk carries only the "content under a name" model of object storage. Writing a file is a method call, and a failed write is not modeled.

**AS7.** Resolution is limited to the names in the configuration. If the requested name is not in the configuration, it does not fall back to the default disk; `Storage` raises an error and the controller turns that error into a 500 response. A silent backup disk is intentionally absent in this lesson, because it would cause a file to live in an unexpected place.

#### The Measurement Core

The same measurement is used in every lesson of the course. `ran.php` records whether an expected part ran in this request; the file is given verbatim in every lesson, and it has no output block, since it is only saved to disk. `Ledger` keeps three lists: the parts placed on the request's path, the parts called within the request, and the parts left outside the request. `classify()` checks them in order. **The decision order is `classify()` itself:** ran, deferred, skipped, not wired. The developer names the part; what is measured are the parts the developer expects to run in this request. In this lesson the `onPath()` call marks that a disk was resolved from the configuration, and the `ran()` call marks that the `write()` method of that disk ran.

```php
<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}
```

#### The Disk Interface and the Two Disks

`storage.php` defines four things. The `Disk` interface gives the contract. `LocalDisk` writes the file to the folder in the working directory with `file_put_contents`; the `cleanup()` method is not part of the interface and exists only to delete the lesson's own leftovers. `MemoryDisk` implements the same contract with an array. `Storage` maps names to disks and resolves the name in the `disk()` call: if no name is given the default is used, and if the name is not in the configuration it throws `InvalidArgumentException`. Finally, `UploadController` takes a file and its content, places the `upload` part on the request's path, leaves its own mark and writes the file to the chosen disk.

There is not a single expression in the controller's body that says which disk is used. The `disk($disk)` call returns a `Disk`, and the `write()` method comes from the contract. When the disk changes, what changes is which name is given to the `Storage` object; the controller sees that this information comes only from outside.

```php
<?php
// storage.php — the disk interface, the local disk, the in-memory disk and the configuration
declare(strict_types=1);
require_once 'ran.php';

interface Disk {
    public function write(string $path, string $content): void;
    public function read(string $path): string;
    public function exists(string $path): bool;
}

final class LocalDisk implements Disk {
    public function __construct(private string $root) {
        if (!is_dir($root)) mkdir($root, 0777, true);
    }
    public function write(string $path, string $content): void {
        Ledger::ran('local');
        file_put_contents($this->root . '/' . $path, $content);
    }
    public function read(string $path): string { return file_get_contents($this->root . '/' . $path); }
    public function exists(string $path): bool { return is_file($this->root . '/' . $path); }
    public function cleanup(): void {
        foreach (glob($this->root . '/*') as $file) unlink($file);
        rmdir($this->root);
    }
}

final class MemoryDisk implements Disk {
    private array $files = [];
    public function write(string $path, string $content): void {
        Ledger::ran('memory');
        $this->files[$path] = $content;
    }
    public function read(string $path): string { return $this->files[$path]; }
    public function exists(string $path): bool { return isset($this->files[$path]); }
}

final class Storage {
    private array $disks = [];
    public function __construct(private string $default) {}
    public function add(string $name, Disk $disk): void { $this->disks[$name] = $disk; }
    public function disk(?string $name = null): Disk {
        $name ??= $this->default;
        if (!isset($this->disks[$name])) {
            throw new InvalidArgumentException("disk not in configuration: $name");
        }
        Ledger::onPath($name);
        return $this->disks[$name];
    }
}

final class UploadController {
    public function __construct(private Storage $storage) {}
    public function upload(string $file, string $content, ?string $disk = null): array {
        Ledger::newRequest();
        Ledger::onPath('upload');
        Ledger::ran('upload');
        try {
            $this->storage->disk($disk)->write($file, $content);
        } catch (InvalidArgumentException $error) {
            return [500, 'no disk'];
        }
        return [201, 'uploaded'];
    }
}
```

#### The Same Call Ran on Two Disks

The driver makes the same `upload()` call three times: without a name, with the name `memory`, and with the name `remote`, which is not in the configuration. After each call the outcome of the three parts is printed. In the first call the default disk is `local`; the controller does not know the disk name when it is called, and the file still goes to the local disk. In the second call the same controller, the same code, the same file name and the same content write to the `memory` disk. Both calls return 201 and not one line in the controller has changed.

```php
<?php
// disk.php — the same upload() call on two disks, the third disk is not in the configuration
require_once 'storage.php';

$local = new LocalDisk('store-lesson06');
$storage = new Storage('local');
$storage->add('local', $local);
$storage->add('memory', new MemoryDisk());
$controller = new UploadController($storage);

foreach ([null, 'memory', 'remote'] as $name) {
    $response = $controller->upload('report.txt', 'hello', $name);
    printf("upload(disk: %s) -> %d %s\n", $name ?? 'default', $response[0], $response[1]);
    foreach (['local', 'memory', 'remote'] as $part) printf("  %-6s %s\n", $part, classify($part));
}
echo 'read from local disk: ', $local->read('report.txt'), "\n";
$local->cleanup();
```

```
upload(disk: default) -> 201 uploaded
  local  ran
  memory not wired
  remote not wired
upload(disk: memory) -> 201 uploaded
  local  not wired
  memory ran
  remote not wired
upload(disk: remote) -> 500 no disk
  local  not wired
  memory not wired
  remote not wired
read from local disk: hello
```

In the first call `local` **ran**, because it was both placed on the path and called; `memory`, although defined in the configuration, was **not wired**, because it was not resolved in this request. In the second call the roles swap. These two outputs together give the outcome of the lesson: the same `write()` call **ran** on both disks. The measurement does not say "written", it says "ran"; whether the file really reached its place is shown separately by the last line of the driver, the `hello` read from the local disk. The result of the third call is the subject of a later section.

#### The Read Side Goes Through the Same Contract

The write side has been measured; the same question can be asked of the read side. `reading.php` writes the same two-line content to both disks, then reads the content back from each disk and calls `exists()` for two separate file names: `summary.txt`, which was written, and `missing.txt`, which was never written. The interface promises only a byte count and a true or false value; the measurement looks at whether the two disks keep this promise in the same way.

```php
<?php
// reading.php — the same write, read and exists calls on two disks
require_once 'storage.php';

$disks = ['local' => new LocalDisk('store-lesson06c'), 'memory' => new MemoryDisk()];
foreach ($disks as $name => $disk) {
    $disk->write('summary.txt', "first line\nline two");
    printf("%-6s read: %d bytes, summary.txt exists: %s, missing.txt exists: %s\n", $name,
        strlen($disk->read('summary.txt')),
        $disk->exists('summary.txt') ? 'yes' : 'no',
        $disk->exists('missing.txt') ? 'yes' : 'no');
}
$disks['local']->cleanup();
```

```
local  read: 19 bytes, summary.txt exists: yes, missing.txt exists: no
memory read: 19 bytes, summary.txt exists: yes, missing.txt exists: no
```

The two lines are the same: the content returns as 19 bytes, the written file exists and the unwritten file does not. The `exists()` method does not throw an error for a file that does not exist; it returns false, so asking whether a file exists before reading is safe on both disks. What remains outside the same contract is what the `read()` method does on a file that does not exist: the interface does not define it, and the two disks may behave differently in that case. For this reason the lesson counts asking `exists()` before reading as part of the contract and keeps this assumption in writing.

#### The Contrast: A Disk Not in the Configuration Was Not Wired

In the third call the name `remote` was given. `Storage::disk()` could not find the name in the configuration, threw the error before placing a mark on the path, and the controller turned the error into a 500 response. In the last block of the output none of the three parts ran: `local` and `memory` were never resolved in this request, and `remote` did not even enter the path because it is not in the configuration. All three were **not wired**.

It is important to see why this is not skipped. A part is skipped when it is on the path and is not called. `remote` was never placed on the path in this request, because resolution leaves no mark until the name is valid. So the deviation did not arise from a branch of the code being skipped but because the connection itself could not be made. The form of the same error as "the disk exists but the write failed" is not in this lesson's model; there the `onPath` mark would be left and the `ran` mark inside `write()` would be missing, and that would be read as **skipped**.

The practical meaning of the contrast is that a configuration error fails loudly instead of silently writing somewhere else. The file is on no disk and the client gets a 500; falling back to a silent default disk would have caused the uploaded file to live in an unexpected place. Here the measurement says only "a part was not wired"; it does not say whether the error was a configuration error or a spelling mistake.

#### The Bounding Measurement

The previous two sections said "the controller is unaware of the disk"; this section measures what that does not cover. When the disk changed, the controller did not change, but the life of the file did. `persistence.php` uses the two disks for the same job: it writes the file, then builds **a new disk instance** of the same kind and checks whether the file is there. The new instance stands for a component rebuilt in a new request.

```php
<?php
// persistence.php — the same interface, a different life
require_once 'storage.php';

foreach (['local', 'memory'] as $kind) {
    $build = fn(): Disk => $kind === 'local' ? new LocalDisk('store-lesson06b') : new MemoryDisk();
    $first = $build();
    $first->write('note.txt', 'persistent');
    $second = $build();              // a new instance: the same interface, built from scratch
    printf("%-6s first exists: %s, exists in new instance: %s\n", $kind,
        $first->exists('note.txt') ? 'yes' : 'no',
        $second->exists('note.txt') ? 'yes' : 'no');
}
(new LocalDisk('store-lesson06b'))->cleanup();
```

```
local  first exists: yes, exists in new instance: yes
memory first exists: yes, exists in new instance: no
```

On the local disk the new instance **sees** the file, because the file stays in the folder and both instances look at the same folder. On the in-memory disk the new instance is empty; the file stayed in the first instance's array. The interface is the same in both cases: the `exists()` method is called on both disks and both return a true or false value. The difference is outside the interface, in the life of the disk.

The consequence is the answer to the question asked in the Next Step of the Caching lesson: when the disk changed, the response did not change, but whether the file the response relied on exists in the next request did. The abstraction hides the form of the call; it does not hide the persistence contract of the call. The controller's upload code was not affected by the change of disk; but code that reads the uploaded file in a later request would get a wrong result with the in-memory disk. This measurement only compares the persistence of the two disks; it does not measure the consistency and latency behavior of real object storage, a network failure or a large file.

#### Summary

- This lesson's outcome is **ran**: the same `upload()` call ran the `write()` method on the local disk and on the in-memory disk, and the controller stayed unaware of the change of disk.
- The `Disk` interface gives the `write`, `read` and `exists` contract; `Storage` maps names to disks and keeps the default separate.
- Its contrast: the `remote` disk, which is not in the configuration, did not enter the path and was **not wired**; the controller returned 500 and the file went to no disk.
- In the bounding measurement the new disk instance saw the file on the local disk and did **not** see the file on the in-memory disk; the abstraction hides the form of the call, not the persistence.
- When the disk changes the controller does not change, but the assumption of the code that reads the file later may change.

#### Next Step

The uploaded file is now written to a disk, and which disk it is written to does not concern the controller. But all of this work is still inside a request, done before the response comes. If some work has to repeat by itself every minute or every day, without being tied to any request, who will run it, and when does this work count as having run from the request's point of view? The Scheduled Tasks lesson defines a recurring task, runs it on the scheduler's tick, and measures whether the request responds without waiting for this task.

### Scheduled Tasks

Source: https://academia.sh/en/courses/web-application-framework/scheduled-tasks

The recurring task definition, the frequency words and the scheduler tick; a task registered in a request running on the tick, a task whose time has not come being skipped on the tick, and the limit the tick interval puts on the task frequency.

In the File Storage lesson the uploaded file was written to a disk, and this work finished inside the request, before the response came. Not every job is like this. Cleaning old files, producing a summary every night, or resetting a counter every minute are jobs that are not tied to any user request; they have to run at certain intervals even if no request comes. This lesson asks: **inside which request does a repeated job count as having run?**

A **scheduled task** is a recurring job defined by a **frequency**: every minute, every hour, every day. What runs the task is not a request but a **scheduler** that works at regular intervals; each of its runs is called a **tick**. On every tick the scheduler looks at the registered tasks and runs the ones whose time has come. A request can only register the task; the moment the task runs is outside the request, on a later tick. The job in the Queues and Jobs lesson also ran outside the request, but that job was born from a request and ran once. Here the job is defined once and repeats itself.

**AS8.** The clock is a variable: a tick takes an integer as the current moment (seconds), and neither `sleep` nor the real clock is called. The scheduler itself is not a loop, a process or a system tool; the `tick()` method is called by hand by the driver. A task runs at the end of an interval that starts at the moment it was registered or at the previous run moment; the first run is one interval after registration, not at the moment of registration.

**AS9.** Three frequency words are defined: `minutely` is 60 seconds, `hourly` is 3600 seconds, `daily` is 86400 seconds. Any other word throws `InvalidArgumentException`. Calendar time, time zones, month and day names, clustering, and two schedulers working at the same time running a task twice are not modeled. There is also no mechanism that makes up for a missed tick; the consequence of this assumption is measured in the Bounding Measurement section.

#### The Measurement Core

The same measurement is used in every lesson of the course. `ran.php` records whether an expected part ran in this request; the file is given verbatim in every lesson, and it has no output block, since it is only saved to disk. `Ledger` keeps three lists: the parts placed on the request's path, the parts called within the request, and the parts left outside the request. `classify()` checks them in order. **The decision order is `classify()` itself:** ran, deferred, skipped, not wired. The developer names the part; what is measured are the parts the developer expects to run in this request. In this lesson the "request" occurs in two places: the real request that registers the task, and each tick of the scheduler. `defer()` is called at registration, and `onPath()` and `ran()` are called on the tick.

```php
<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}
```

#### The Task Definition and the Scheduler

`scheduler.php` defines three classes. `Counter` counts how many times a job ran; what is measured is not the body of the job but whether the job ran. `Scheduler::register()` takes a task with a name, a frequency word and a function, converts the frequency to seconds, computes the first run moment as `now + interval`, and writes the task to the list of parts left outside the request. `Scheduler::tick()` opens a new measurement round, places each task on the path, and if the current moment has not reached the task's `next` value, moves on to the next task without touching it. If it has, it updates the next run moment to `now + interval`, marks the task as ran and calls the function. `ReportController` registers the `report` task in a request so that it runs every minute, and returns 202.

The 202 response is meaningful: the request accepted something but did not do it. The controller does not run the task and does not wait for it to run; the response has returned before the task has run. This has the same structure as the response in the Queues and Jobs lesson, the only difference being that the run moment is decided not by a queue worker but by a clock value.

```php
<?php
// scheduler.php — the task definition, the frequency words and the scheduler tick
declare(strict_types=1);
require_once 'ran.php';

final class Counter {
    public array $counts = [];
    public function increment(string $name): void { $this->counts[$name] = ($this->counts[$name] ?? 0) + 1; }
    public function read(string $name): int { return $this->counts[$name] ?? 0; }
}

final class Scheduler {
    private const INTERVAL = ['minutely' => 60, 'hourly' => 3600, 'daily' => 86400];
    private array $tasks = [];

    public function register(string $name, string $frequency, callable $job, int $now): void {
        if (!isset(self::INTERVAL[$frequency])) {
            throw new InvalidArgumentException("unknown frequency: $frequency");
        }
        $interval = self::INTERVAL[$frequency];
        $this->tasks[$name] = ['interval' => $interval, 'next' => $now + $interval, 'job' => $job];
        Ledger::defer($name);
    }

    public function tick(int $now): void {
        Ledger::newRequest();
        foreach (array_keys($this->tasks) as $name) {
            Ledger::onPath($name);
            if ($now < $this->tasks[$name]['next']) {
                continue;
            }
            $this->tasks[$name]['next'] = $now + $this->tasks[$name]['interval'];
            Ledger::ran($name);
            ($this->tasks[$name]['job'])();
        }
    }
}

final class ReportController {
    public function __construct(private Scheduler $scheduler, private Counter $counter) {}
    public function register(int $now): array {
        Ledger::newRequest();
        $this->scheduler->register('report', 'minutely', fn() => $this->counter->increment('report'), $now);
        return [202, 'registered'];
    }
}
```

#### The Task Registered in a Request Ran on the Tick

The driver makes the request at time 0, then ticks the scheduler at time 60. Right after the request the outcome and the counter are read, and they are read again after the tick. The only difference between the two readings is that the scheduler has worked.

```php
<?php
// deferral.php — a task registered in a request runs on the tick
require_once 'scheduler.php';

$counter = new Counter();
$scheduler = new Scheduler();
$controller = new ReportController($scheduler, $counter);

$response = $controller->register(0);
printf("request (time 0)  -> %d %s\n", $response[0], $response[1]);
printf("  report %s, job run count: %d\n", classify('report'), $counter->read('report'));

$scheduler->tick(60);
printf("tick (time 60)\n");
printf("  report %s, job run count: %d\n", classify('report'), $counter->read('report'));
```

```
request (time 0)  -> 202 registered
  report deferred, job run count: 0
tick (time 60)
  report ran, job run count: 1
```

Right after the request, `report` was **deferred**: the task was registered but not called in this request; the counter is 0. After the tick the same part **ran**: the task entered the path, its time had come and the function was called; the counter is 1. For this reason the outcome of the lesson reads as **deferred**: from the request's point of view the job was not done, it was only recorded that it would be done. The time between "deferred" and "ran" cannot be smaller than the task's frequency; the first run is one interval later. The measurement does not say whether the job finished; it says only in which round it ran.

#### The Contrast: A Task Whose Time Had Not Come Was Skipped on the Tick

Not every tick of the scheduler runs the task. After the same registration, the driver ticks the scheduler at times 30, 59, 60 and 61. After each tick the outcome of `report` and the total run count are printed.

```php
<?php
// skipping.php — a task whose time has not come is skipped on the tick
require_once 'scheduler.php';

$counter = new Counter();
$scheduler = new Scheduler();
(new ReportController($scheduler, $counter))->register(0);

foreach ([30, 59, 60, 61] as $time) {
    $scheduler->tick($time);
    printf("tick (time %2d)  report %-10s total: %d\n", $time, classify('report'), $counter->read('report'));
}
```

```
tick (time 30)  report skipped    total: 0
tick (time 59)  report skipped    total: 0
tick (time 60)  report ran        total: 1
tick (time 61)  report skipped    total: 1
```

At times 30 and 59 the task entered the path but was not called because it had not reached its `next` value: **skipped**. At time 60 its time came and it **ran**. At time 61 the task entered the path again, but its `next` value is now 120, and on this tick too it was **skipped**. The total stayed at 1. The first three lines are the contrast of the lesson's observation: the same code, the same registration, but a different outcome because the time of the tick is different.

The difference between "skipped" here and "not wired" in the File Storage lesson is the existence of the path. Here the task enters the path on every tick, because the scheduler evaluates it every time. Being skipped does not mean it was not evaluated; it means it was evaluated and its time had not come. This distinction matters: if a task has not entered the path on a tick, it has no registration or the scheduler never ran.

#### The Bounding Measurement

What happens if the scheduler misses a tick? `frequency.php` runs three tasks, with three frequency words, over a two-hour stream of ticks; it builds the stream twice with two different tick intervals. In the first stream the scheduler ticks every 60 seconds, in the second every 300 seconds. The tasks start from registration moment 0.

```php
<?php
// frequency.php — three frequency words, a two-hour tick stream, two different tick intervals
require_once 'scheduler.php';

foreach ([60, 300] as $tickInterval) {
    $counter = new Counter();
    $scheduler = new Scheduler();
    foreach (['minutely' => 'minutely', 'hourly' => 'hourly', 'daily' => 'daily'] as $frequency => $name) {
        $scheduler->register($name, $frequency, fn() => $counter->increment($name), 0);
    }
    for ($time = $tickInterval; $time <= 7200; $time += $tickInterval) {
        $scheduler->tick($time);
    }
    printf("tick interval %3d s: minutely %3d, hourly %d, daily %d\n", $tickInterval,
        $counter->read('minutely'), $counter->read('hourly'), $counter->read('daily'));
}
```

```
tick interval  60 s: minutely 120, hourly 2, daily 0
tick interval 300 s: minutely  24, hourly 2, daily 0
```

With a scheduler that ticks every 60 seconds, the `minutely` task ran 120 times, the `hourly` task 2 times and the `daily` task never: in two hours there are 120 minutes, 2 hours, and a long way to go before a day is complete. With a scheduler that ticks every 300 seconds, the `minutely` task ran only 24 times; the reason is that a task can run at most once per tick, not once per minute. There are 24 ticks in two hours, and on each tick the task runs at most once. The 96 missed runs were not made up: in this lesson there is no mechanism that takes back a missed tick. The `hourly` task ran 2 times in both streams, because 300 seconds divides 3600 and the ticks coincide with the start of the hours.

The result of this measurement is as follows: the frequency word decides how often the task can run at most; the tick interval of the scheduler limits how often it actually runs. The measurement gives only the result in this model. Another scheduler may make up for missed runs in order, or may run the same task more than once in a tick; that behavior is not measured here, and the driver belongs only to this lesson's model.

The other end of the tick interval can also be measured: what happens if two ticks come at the same moment? After the same registration, `double.php` ticks the scheduler twice at time 60. On the first tick the task runs and its `next` value is moved to 120; on the second tick the current moment is still 60, and 60 is less than 120.

```php
<?php
// double.php — two ticks at the same moment: the second tick does not run the task again
require_once 'scheduler.php';

$counter = new Counter();
$scheduler = new Scheduler();
(new ReportController($scheduler, $counter))->register(0);

foreach ([1, 2] as $round) {
    $scheduler->tick(60);
    printf("tick %d (time 60)  report %-10s total: %d\n", $round, classify('report'), $counter->read('report'));
}
```

```
tick 1 (time 60)  report ran        total: 1
tick 2 (time 60)  report skipped    total: 1
```

On the second tick the task entered the path but was not called: **skipped**, and the total stayed at 1. A double tick at the same moment does not run the task twice, because the run moment is held in the task's own `next` value, not in the number of ticks. This protection holds only for a single scheduler object: the `next` value lives inside that object, and two separate scheduler processes can run the same task at the same moment. AS9 left this situation out of scope, and the measurement in this section does not cover it either.

#### Summary

- This lesson's outcome is **deferred**: the `report` task registered in the request was not called within the request, the counter stayed at 0, and the task ran on the tick at time 60.
- A scheduled task is defined with a frequency word (`minutely`, `hourly`, `daily`), and its first run is one interval after registration.
- Its contrast: a task whose time had not come entered the path on the ticks at times 30, 59 and 61 but was not called, and was **skipped**; it **ran** at time 60.
- In the bounding measurement a tick stream of 60 seconds ran the `minutely` task 120 times and a stream of 300 seconds ran it 24 times; the missed runs were not made up.
- A double tick at the same moment did not run the task twice; on the second tick the task was **skipped**.
- The frequency of a task is a target, not a guarantee; the tick interval of the scheduler limits how often it actually runs.

#### Next Step

Scheduled tasks tie work to time that the user does not wait for. But time is not the only variable that shapes an application's response. If the task in this lesson were sending a notification to a user, the language in which the notification is written would also be a choice. And what happens if there is no text in the requested language: an error, an empty response, or a text from another language? The Localization lesson builds text from language files, placeholders and plural forms, and measures how a key that is missing in the requested language is wired to the response.

### Localization

Source: https://academia.sh/en/courses/web-application-framework/localization

Language arrays, placeholders, plural forms and the fallback language; the text of the requested language not being wired when the key is missing and the fallback language stepping in without an error, the requested language running when the key exists and the fallback language hiding a missing translation.

In the Scheduled Tasks lesson we asked when a task would run; if the running task sends a notification to a user, the language in which the notification is written is also a choice. If the text of the response is embedded in the code, every language needs a separate code path. Moving the text out of the code, into a table separated by language, reverses the work: the code stays the same, and the table changes. This lesson asks: **what happens to the response if there is no text in the requested language?**

**Localization** is choosing the texts of an application according to the user's language. The texts live in a **language file**: in this lesson a language file is a PHP array that maps keys to text. The variable parts inside a text are marked with a **placeholder**: a piece such as `:name` is replaced with the value that comes with the call. The forms of a text that change with a number are called **plural forms**: "1 item" and "3 items" are two forms of the same key. The language that is looked at second when a key is missing in the requested language is called the **fallback language**.

**AS10.** A language file is a PHP array, and two languages are defined: `tr` and `en`. The fallback language is `tr` and it has one level: if the key is missing in the requested language, only the fallback language is looked at, and the fallback has no fallback of its own. Keys are flat names; a name such as `general.unknown` contains a dot but does not express a nested structure. The language code carries no region part, date, number and currency formatting are not modeled, and the `discount` key is deliberately left missing in the `en` file. The Turkish language data stays Turkish in this lesson, because the subject of the lesson is the difference between two languages.

**AS11.** The plural form separates only two cases: if the number is exactly 1, `one`, otherwise `many`. This is correct for English; that plural rules are far more complex from language to language (there are languages in which zero, two or certain number endings take separate forms) is not in the lesson's model. Turkish does not pluralize the noun after a number, so the two forms in the `tr` file are the same. The placeholder has the form `:name`, and the related part of the text is filled by a plain string replacement; value formatting and escaping are not modeled.

#### The Measurement Core

The same measurement is used in every lesson of the course. `ran.php` records whether an expected part ran in this request; the file is given verbatim in every lesson, and it has no output block, since it is only saved to disk. `Ledger` keeps three lists: the parts placed on the request's path, the parts called within the request, and the parts left outside the request. `classify()` checks them in order. **The decision order is `classify()` itself:** ran, deferred, skipped, not wired. The developer names the part; what is measured are the parts the developer expects to run in this request. In this lesson a part is a piece of text, and its name has the form `language/key`: `en/discount` measures the question "did the `discount` text in the English file enter the response in this request". When a text is used, both `onPath()` and `ran()` are called; when it is not used, neither is called.

```php
<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}
```

#### Language Arrays, Placeholders and the Fallback Language

`localization.php` defines three parts. `Translator::get()` tries the requested language and the fallback language in order: when it finds the key, it puts the part on the path, marks it as ran and fills the text; if it finds the key in neither, it returns the key itself. `fill()` picks the `one` or `many` form according to the number if the text is an array, and then replaces the placeholders with `strtr`. `languageArrays()` gives the two language files. `CartController` takes a language, a name, a number and a list of keys, translates each key and joins the parts into a single response.

In this structure the controller knows the text of no language. The language choice reaches the controller as a string, and the resolution is entirely in `Translator`. The fallback language is given when `Translator` is built; so changing the fallback does not change the controller.

```php
<?php
// localization.php — language arrays, placeholders, plural forms and the fallback language
declare(strict_types=1);
require_once 'ran.php';

final class Translator {
    public function __construct(private array $languages, private string $fallback) {}

    public function get(string $language, string $key, array $values = []): string {
        foreach ([$language, $this->fallback] as $candidate) {
            if (!isset($this->languages[$candidate][$key])) {
                continue;
            }
            Ledger::onPath("$candidate/$key");
            Ledger::ran("$candidate/$key");
            return $this->fill($this->languages[$candidate][$key], $values);
        }
        return $key;
    }

    private function fill(string|array $text, array $values): string {
        if (is_array($text)) {
            $text = ($values['count'] ?? 0) === 1 ? $text['one'] : $text['many'];
        }
        $placeholders = [];
        foreach ($values as $name => $value) {
            $placeholders[':' . $name] = (string) $value;
        }
        return strtr($text, $placeholders);
    }

    public function keys(string $language): array { return array_keys($this->languages[$language]); }
}

function languageArrays(): array {
    return [
        'tr' => [
            'greeting' => 'Merhaba, :name',
            'cart' => ['one' => ':count ürün', 'many' => ':count ürün'],
            'discount' => 'Sepette indirim var',
        ],
        'en' => [
            'greeting' => 'Hello, :name',
            'cart' => ['one' => ':count item', 'many' => ':count items'],
        ],
    ];
}

final class CartController {
    public function __construct(private Translator $translator) {}
    public function show(string $language, string $name, int $count, array $keys): array {
        Ledger::newRequest();
        $lines = [];
        foreach ($keys as $key) {
            $lines[] = $this->translator->get($language, $key, ['name' => $name, 'count' => $count]);
        }
        return [200, implode(' | ', $lines)];
    }
}
```

#### The Text of the Requested Language Was Not Wired

The driver asks for only the `discount` key in the `en` language. This key is deliberately missing in the `en` file. After the response, the outcome of two parts is written: the `discount` text in the English file and the same text in the Turkish file.

```php
<?php
// fallback.php — the key is missing in the requested language: the fallback language steps in
require_once 'localization.php';

$controller = new CartController(new Translator(languageArrays(), 'tr'));
$response = $controller->show('en', 'Alice', 3, ['discount']);
printf("show(language: en, discount) -> %d %s\n", $response[0], $response[1]);
foreach (['en/discount', 'tr/discount'] as $part) {
    printf("  %-11s %s\n", $part, classify($part));
}
```

```
show(language: en, discount) -> 200 Sepette indirim var
  en/discount not wired
  tr/discount ran
```

The request did not fail; it returned 200 and the response carried the Turkish text. `en/discount` was **not wired**: there is no such text in the English file, so it never entered the path. `tr/discount` **ran**: the fallback language stepped in and put the text into the response. The outcome of the lesson therefore reads as **not wired**, but this is not an error, it is a path: instead of a text that does not exist in the language the user asked for, the user sees a text in another language, and the application keeps working.

Saying "skipped" here would be wrong. A skipped part is a part that was placed on the path but not called; `en/discount` was never placed on the path, because the resolution does not put a text it could not find in the file on the path. The text could not be wired; it was not passed over as the result of a condition.

#### The Contrast: The Requested Language Ran When the Key Existed

This time the same controller is given the `greeting` and `cart` keys, which exist in both languages. The driver makes three requests: in the `en` language with one item, in the `en` language with three items, and in the `tr` language with three items. After each request the outcome of the `greeting` text in the two languages is written.

```php
<?php
// present.php — when the key exists in the requested language, the text of that language runs
require_once 'localization.php';

$controller = new CartController(new Translator(languageArrays(), 'tr'));
foreach ([['en', 1], ['en', 3], ['tr', 3]] as [$language, $count]) {
    $response = $controller->show($language, 'Alice', $count, ['greeting', 'cart']);
    printf("show(language: %s, count: %d) -> %d %s\n", $language, $count, $response[0], $response[1]);
    foreach (['en/greeting', 'tr/greeting'] as $part) {
        printf("  %-13s %s\n", $part, classify($part));
    }
}
```

```
show(language: en, count: 1) -> 200 Hello, Alice | 1 item
  en/greeting   ran
  tr/greeting   not wired
show(language: en, count: 3) -> 200 Hello, Alice | 3 items
  en/greeting   ran
  tr/greeting   not wired
show(language: tr, count: 3) -> 200 Merhaba, Alice | 3 ürün
  en/greeting   not wired
  tr/greeting   ran
```

When the key was in the requested language, the text of the requested language **ran** and that of the fallback language was **not wired**. So it is the reverse of the `discount` example: the fallback language never entered the resolution, because the first language tried was enough. The responses show two things together. The plural form is chosen by the number: in the `en` language 1 item became `1 item` and 3 items became `3 items`; in the `tr` language it became `3 ürün`, because the two forms are the same in the Turkish file. The placeholder was filled in every request too: `:name` became `Alice` in every language, and `:count` took the number that came from the call.

The classes of the two languages mirror each other: when one language runs, the other is not wired. This means the fallback language steps in only when the requested language fails.

#### The Bounding Measurement

The fallback language protects the user from an error, but at the same time it hides a missing translation. `missing.php` first requests all the keys in the Turkish file in the `en` language and lists which of them were not wired in the English file, that is, which came from the fallback. Then it requests a key that exists in no language.

```php
<?php
// missing.php — the fallback hides a missing translation; a key found in no language returns under its own name
require_once 'localization.php';

$translator = new Translator(languageArrays(), 'tr');
$controller = new CartController($translator);
$keys = $translator->keys('tr');
$controller->show('en', 'Alice', 2, $keys);
$missing = array_filter($keys, fn(string $k) => classify("en/$k") === 'not wired');
printf("en request: %d keys, from the fallback: %s\n", count($keys), implode(', ', $missing));

$response = $controller->show('en', 'Alice', 2, ['general.unknown']);
printf("in no language -> %d %s\n", $response[0], $response[1]);
foreach (['en/general.unknown', 'tr/general.unknown'] as $part) {
    printf("  %-19s %s\n", $part, classify($part));
}
```

```
en request: 3 keys, from the fallback: discount
in no language -> 200 general.unknown
  en/general.unknown  not wired
  tr/general.unknown  not wired
```

The first line shows that behind the 200 response there is a translation debt: one of the three keys came from the fallback. The response looks complete from the outside; the gap can be noticed only by measuring, that is, by looking at which part was not wired. This is the subject of a test or an audit rule: the fallback language protects the user experience, but it does not say that a translation is missing.

The second part shows the case in which the fallback is not enough either. Because the key is in neither language, `Translator` returned the name of the key; the response is again 200 and both parts were **not wired**. The user sees the text `general.unknown` in the response. In this model no error is thrown; another application may choose to throw an error or to return an empty text. The measurement does not say which of these choices is right, it only shows what happens in this model.

#### Summary

- This lesson's outcome is **not wired**: the `discount` key was missing in the `en` language, `en/discount` did not enter the path, and the fallback language put the text `tr/discount` into the response without an error.
- A language file is a PHP array; the text is built with a placeholder (`:name`) and a `one` or `many` form chosen by the number.
- Its contrast: when the key existed in the requested language, the text of that language **ran** and that of the fallback language was **not wired**; the plural form was separated as `1 item` and `3 items`.
- In the bounding measurement the fallback language hid a missing translation: one of three keys came from the fallback, and the response stayed 200.
- A key found in no language returned under its own name, and the text of both languages was **not wired**.

#### Next Step

In this course we measured in every lesson whether a part ran: authentication, queue, event, notification, cache, file, scheduler and language. Each came by a different path, and each had a contrast of its own. But can these measurements be turned into something that checks itself after every change, instead of a driver and an output read by hand? The last lesson of the course asks how all of these can be verified from a request to a response and at the level of single units.

### Testing

Source: https://academia.sh/en/courses/web-application-framework/testing

The feature test and the unit test, a small verification function; a job being recorded but not running in a fake queue, the job running once the queue is drained, and the fake queue not measuring the body of the job.

In the Localization lesson the fallback language hid a missing translation behind an error-free response. The same happened in earlier lessons too: a job put in a queue runs after the response, and the response returns without waiting for the job. So how can we know inside a test, without a real worker, that the job ran? This lesson asks: **if a test verifies that a job was recorded, has it also verified that the job ran?**

A **feature test** gives a request to the application and verifies the response and the traces left beside it. A **unit test** tests a single class or function, separated from its surroundings. A **fake** is a simple look-alike put in place of a real dependency that records what is done to it: in this lesson the fake queue does not run the job, it only stores it.

**AS12.** No test library is used. The lesson builds a verifier with two functions in a `Test` class: `verify()` throws an error if the condition is false, and `run()` runs a test and writes passed or failed. There is no equality check, array comparison, detailed diff output or test discovery; every test is called by hand.

**AS13.** The queue is not real: `FakeQueue` puts the job in an array and does not run it until `process()` is called. `push()` marks the job with `defer()`, and `process()` marks every job with `ran()` and runs it. A real driver, retries, delays and error handling are not modeled. The job is a small calculation: `ThumbnailJob`, which divides the width by four, and `FaultyThumbnailJob`, which deliberately works wrongly.

#### The Measurement Core

The same measurement is used in every lesson of the course. `ran.php` records whether an expected part ran in this request; the file is given verbatim in every lesson, and it has no output block, since it is only saved to disk. `Ledger` keeps three lists: the parts placed on the request's path, the parts called within the request, and the parts left outside the request. `classify()` checks them in order. **The decision order is `classify()` itself:** ran, deferred, skipped, not wired. The developer names the part; what is measured are the parts the developer expects to run in this request. The part measured in this lesson is the `thumbnail` job: when the job is recorded, `onPath()` and `defer()` are called, and when the queue is drained, `ran()` is called.

```php
<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}
```

#### The Code Under Test

`app.php` holds everything that is tested: the `Job` interface, two jobs, `FakeQueue` and `UploadController`. The controller takes a width, puts the job in the queue and returns 201. Which job class is used is given to the constructor; the default is the correct one. `test.php` is the verifier.

```php
<?php
// app.php — the code under test: the job interface, the fake queue and the upload controller
declare(strict_types=1);
require_once 'ran.php';

interface Job {
    public function name(): string;
    public function run(): int;
}

final class ThumbnailJob implements Job {
    public function __construct(private int $width) {}
    public function name(): string { return 'thumbnail'; }
    public function run(): int { return intdiv($this->width, 4); }
}

final class FaultyThumbnailJob implements Job {
    public function __construct(private int $width) {}
    public function name(): string { return 'thumbnail'; }
    public function run(): int { return $this->width * 4; }   // wrong direction
}

final class FakeQueue {
    public array $jobs = [];
    public function push(Job $job): void {
        $this->jobs[] = $job;
        Ledger::defer($job->name());
    }
    public function process(): array {
        $results = [];
        foreach ($this->jobs as $job) {
            Ledger::ran($job->name());
            $results[] = $job->run();
        }
        $this->jobs = [];
        return $results;
    }
}

final class UploadController {
    public function __construct(private FakeQueue $queue, private string $jobClass = ThumbnailJob::class) {}
    public function upload(int $width): array {
        Ledger::newRequest();
        Ledger::onPath('thumbnail');
        $this->queue->push(new ($this->jobClass)($width));
        return [201, 'uploaded'];
    }
}
```

```php
<?php
// test.php — no library: a small verifier with two functions
declare(strict_types=1);

final class Test {
    public static int $passed = 0;
    public static int $failed = 0;

    public static function verify(bool $condition, string $message): void {
        if (!$condition) {
            throw new RuntimeException($message);
        }
    }

    public static function run(string $name, callable $test): void {
        try {
            $test();
            self::$passed++;
            $status = 'passed';
        } catch (RuntimeException $error) {
            self::$failed++;
            $status = 'failed — ' . $error->getMessage();
        }
        printf("%-28s %s\n", $name, $status);
    }

    public static function summary(): void {
        printf("total: %d passed, %d failed\n", self::$passed, self::$failed);
    }
}
```

#### The Feature Test: The Job Was Recorded and Did Not Run

`feature.php` gives the request to the controller and verifies three things: is the response 201, did the job enter the queue, and did the job not run during the response. The last line writes the class of the `thumbnail` part.

```php
<?php
// feature.php — feature test: from request to response, with a fake queue
require_once 'app.php';
require_once 'test.php';

$queue = new FakeQueue();
$response = (new UploadController($queue))->upload(800);

Test::run('response returns 201', fn() => Test::verify($response[0] === 201, "status {$response[0]}"));
Test::run('job entered the queue', fn() => Test::verify(count($queue->jobs) === 1, 'queue empty'));
Test::run('job did not run in response', fn() => Test::verify(classify('thumbnail') === 'deferred', 'class ' . classify('thumbnail')));
Test::summary();
printf("thumbnail: %s\n", classify('thumbnail'));
```

```
response returns 201         passed
job entered the queue        passed
job did not run in response  passed
total: 3 passed, 0 failed
thumbnail: deferred
```

Three tests passed and the job was **deferred**: it was recorded, but it did not run. This is exactly what the test verifies. The sentence "the job entered the queue" is true, and the sentence "the job ran" was never tried in this test. Because the fake queue does not run the job, the feature test cannot say anything about what the job does.

#### The Contrast: The Job Ran When the Queue Was Drained

`unit.php` makes the same request, then drains the queue with `process()`. This time `thumbnail` **ran**. The result of the job is also verified: with a width of 800, the result must be 200. The last test tests the job directly, independent of the queue; this is the unit test.

```php
<?php
// unit.php — contrast: when the queue is drained with process(), the job runs; the unit test tests the job directly
require_once 'app.php';
require_once 'test.php';

$queue = new FakeQueue();
(new UploadController($queue))->upload(800);
$results = $queue->process();

Test::run('process() ran the job', fn() => Test::verify(classify('thumbnail') === 'ran', 'class ' . classify('thumbnail')));
Test::run('thumbnail width', fn() => Test::verify($results === [200], 'result ' . json_encode($results)));
Test::run('job on its own', fn() => Test::verify((new ThumbnailJob(100))->run() === 25, 'result wrong'));
Test::summary();
printf("thumbnail: %s\n", classify('thumbnail'));
```

```
process() ran the job        passed
thumbnail width              passed
job on its own               passed
total: 3 passed, 0 failed
thumbnail: ran
```

The same job, the same controller, the same request: the class passed from deferred to ran only when `process()` was called. So what changes the class is not the request itself, it is draining the queue. The third test does not touch the queue at all: `ThumbnailJob` is built directly, `run()` is called and the result is read as 25.

#### The Bounding Measurement

The fake queue does not measure the body of the job. `limit.php` gives the controller the deliberately wrong `FaultyThumbnailJob`. First the same feature test runs, then the unit test of the same job.

```php
<?php
// limit.php — the fake queue does not measure the body of the job: a faulty job passes the feature test
require_once 'app.php';
require_once 'test.php';

$queue = new FakeQueue();
$response = (new UploadController($queue, FaultyThumbnailJob::class))->upload(800);

echo "feature test\n";
Test::run('response returns 201', fn() => Test::verify($response[0] === 201, "status {$response[0]}"));
Test::run('job entered the queue', fn() => Test::verify(count($queue->jobs) === 1, 'queue empty'));
Test::run('job did not run in response', fn() => Test::verify(classify('thumbnail') === 'deferred', 'class wrong'));

echo "unit test\n";
$result = (new FaultyThumbnailJob(800))->run();
Test::run('thumbnail width', fn() => Test::verify($result === 200, "expected 200, got $result"));
Test::summary();
```

```
feature test
response returns 201         passed
job entered the queue        passed
job did not run in response  passed
unit test
thumbnail width              failed — expected 200, got 3200
total: 3 passed, 1 failed
```

All three verifications of the feature test passed with the faulty job as well, because none of them looks at the body of the job: with a width of 800 the job returns 3200, but the fake queue never ran it. Only the unit test caught the fault. A feature test verifies that the path is wired; it does not verify that the right work is done at the end of the path. The two cannot replace each other.

This distinction has a cost too. A feature test that really runs the job has to drain the queue with `process()`, and the job then runs not after the response but inside the test. Such a test does not measure the deferral itself, only the result of the job. One of the two is chosen according to which question is asked.

#### Summary

- This lesson's outcome is **deferred**: in the fake queue the `thumbnail` job was recorded (`defer()`), but it did not run in the feature test.
- Without a test library, a verifier with two functions (`verify()` and `run()`) was enough to write feature and unit tests.
- Its contrast: when `process()` was called the same job **ran**; what changes the class is draining the queue, not the request.
- In the bounding measurement a deliberately faulty job passed the feature test, and the unit test caught the result 3200 instead of 200.
- A feature test verifies that the path is wired, and a unit test verifies that the job does the right work; one does not take the place of the other.

#### Course Wrap-Up

In this course, across 21 lessons, we measured whether a part ran in a request: the foundations of the framework, views and data, and application services. Each lesson carried its own outcome and a contrasting measurement. The table below collects all of them in one place; the outcome and contrast cells are taken from the summary of the source lesson.

| Lesson | Part measured | Outcome | Contrasting measurement |
|---|---|---|---|
| Why a Framework | `save` action, `cache` listener | ran | Core: 4 requests, 12 parts: 4 ran, 3 skipped, 3 deferred, 2 not wired |
| The Request–Response Lifecycle | Job left with `afterResponse()` | deferred | The same job called directly in the action ran |
| Routing | Shadowed `form` route | not wired | `form` ran after the order changed or after `{id:\d+}` narrowed the route |
| Controllers | Action, dependency resolved | ran | The undefined `edit` action gave 404, the part was not wired |
| Middleware | `validation`, `save` | skipped | In the request with a session all four parts ran |
| The Object Container and Facades | Overridden `FileWriter` binding | not wired | In the container with a single binding `FileWriter` ran |
| The Template Engine | Escaping step | ran | In raw output syntax escaping was not wired |
| Forms and Validation | Save step, invalid input 422 | skipped | With valid input the save ran, 201 |
| Object–Table Binding | Save hook | ran | A field not in the allow list was not wired |
| Relationships | Relationship query in lazy loading | deferred | In eager loading the relationship ran together with the list |
| Migrations and Seeding | Registered migration, second run | skipped | A newly added migration ran |
| Pagination and Resource Transformation | A column added to the model reaching the response | not wired | A column added to the transformation definition ran |
| Authentication and Authorization | `edit` action, gate 403 | skipped | For the owner of the post `edit` ran, 200 |
| Queues and Jobs | Thumbnail job | deferred | With the sync driver the job ran |
| Events and Listeners | `index` listener, name did not match | not wired | When the name matched the listener ran |
| Notifications | Selected mail and database channels | ran | Channels not selected were not wired |
| Caching | `compute`, hit on the second request | skipped | After invalidation `compute` ran again |
| File Storage | `upload()` and `write()`, two disks | ran | The `remote` disk, not in the configuration, was not wired, 500 |
| Scheduled Tasks | `report` task | deferred | Ran at time 60, skipped at 30/59/61 |
| Localization | `en/discount` text | not wired | When the key existed the requested language ran |
| Testing | Job in the fake queue | deferred | When `process()` was called the job ran |

The distribution of the lessons reads as 6 ran, 5 deferred, 5 skipped, 5 not wired. This counts the outcome of each of the 21 lessons. The distribution of the core in the Why a Framework lesson counts something else: of that lesson's 12 expected parts, 4 ran, 3 were deferred, 3 were skipped and 2 were not wired; that is, in 4 requests 4 parts ran and 8 parts did not run.

The distribution in the table shows that in an application most of the expected parts do not run in a request, while the response may still be 200 or 201. Knowing which class came by which path helps one know what is being verified when writing a test. The question the course leaves is measuring the quality of this application: another subject area, such as the form of the code, its repetition and its classes of errors.

