---
title: 'Why a Framework'
source: 'https://academia.sh/en/courses/web-application-framework/why-a-framework'
course: 'The Web Application Framework'
language: en
updated: '2026-10-21T05:03:39+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Explains inversion of control by comparing an if chain that the script drives with a registered action that the framework calls'
    - 'Reads the rule that classifies a part as ran, deferred, skipped or not wired, together with its decision order'
    - 'Runs the core measurement of four requests and twelve expected parts and interprets the 4, 3, 3 and 2 distribution'
    - 'Measures that the classification depends on how the developer names the expected part'
---

# 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?
