Skip to content
academia.sh

Lesson 04 / 21

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.

Published Updated

In this lesson

  • Reads the binding of a route to a controller class and an action method, and the resolution of a constructor dependency by the object container
  • Compares the rule by which a resource controller exposes seven actions as seven routes with the single-action controller
  • Measures that for an action to fall under ran, the controller must be built
  • Measures whether a request falls on 404 or on another action when the route of an undefined action was never registered
Contents

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

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close