Kodokon kodokon.com

A hand-built MVC architecture

Write a complete micro MVC framework - a regex router, injected controllers, buffered views - to understand what frameworks do on your behalf.

11 min · 3 questions

Open this lesson in Kodokon

Understanding a framework means knowing how to write a small one. Every modern MVC architecture rests on a front controller: a single index.php receives all requests (the server rewrites every URL to it), then a router matches the HTTP method and path to a handler. Ours converts patterns like /posts/{id} into regular expressions with named capture groups: preg_match then fills $found['id'] directly, with no manual splitting of the URL.

PHP
<?php
declare(strict_types=1);

final class Router
{
    private array $routes = [];

    public function add(
        string $method,
        string $pattern,
        callable $handler
    ): void {
        $regex = preg_replace(
            '#\{(\w+)\}#',
            '(?P<$1>[^/]+)',
            $pattern
        );
        $this->routes[] = [
            $method, "#^$regex$#", $handler
        ];
    }

    public function dispatch(
        string $method,
        string $uri
    ): mixed {
        foreach ($this->routes as $route) {
            [$m, $re, $handler] = $route;
            if ($m !== $method) {
                continue;
            }
            if (preg_match($re, $uri, $found) === 1) {
                return $handler($found);
            }
        }
        http_response_code(404);
        return 'Page not found';
    }
}
The pattern /posts/{id} becomes #^/posts/(?P<id>[^/]+)$#.

The controller orchestrates: it reads the parameters, queries the model - the only layer allowed to talk to the database - and picks a view. Dependencies come in through the constructor: this is dependency injection without a container, and promoting readonly properties guarantees they will never change after construction. The model returns an explicit null when the resource does not exist, rather than false: the controller can then decide on the 404.

PHP
<?php
declare(strict_types=1);

final class PostModel
{
    public function __construct(
        private readonly PDO $pdo
    ) {}

    public function find(int $id): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT * FROM posts WHERE id = :id'
        );
        $stmt->execute(['id' => $id]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        return $row === false ? null : $row;
    }
}

final class PostController
{
    public function __construct(
        private readonly PostModel $posts
    ) {}

    public function show(array $params): string
    {
        $id = (int) $params['id'];
        $post = $this->posts->find($id);
        if ($post === null) {
            http_response_code(404);
            return render('not-found');
        }
        return render('post', ['post' => $post]);
    }
}
The render function is defined in the next block.

The view is a plain PHP file that only displays variables already prepared for it. Two internal mechanisms make it possible: extract turns the data array into local variables, and output buffering (ob_start then ob_get_clean) captures the output to return it as a string instead of sending it immediately - indispensable for composing nested templates or deciding on headers after rendering.

PHP
<?php
declare(strict_types=1);

function render(
    string $view,
    array $data = []
): string {
    extract($data, EXTR_SKIP);
    ob_start();
    require __DIR__ . "/views/$view.php";
    return (string) ob_get_clean();
}

$pdo = new PDO('sqlite:blog.db');
$controller = new PostController(
    new PostModel($pdo)
);

$router = new Router();
$router->add(
    'GET',
    '/posts/{id}',
    fn (array $p): string => $controller->show($p)
);

$path = parse_url(
    $_SERVER['REQUEST_URI'],
    PHP_URL_PATH
);

echo $router->dispatch(
    $_SERVER['REQUEST_METHOD'],
    (string) $path
);
The complete front controller: rendering, wiring and dispatch.

Knowledge check

Make sure you remember the key points of this lesson.

  1. In this router, what does converting the pattern /posts/{id} produce?
    • A regular expression with a named capture group, then accessible via $found['id']
    • An associative array of the URL segments
    • A plain string comparison, segment by segment
  2. Why does the render function use ob_start?
    • To compress the HTML output before sending it
    • To capture the view's output and return it as a string instead of sending it immediately to the browser
    • To prevent the view from executing PHP code
    • To cache the view's rendering on disk
  3. What is the role of the front controller?
    • Serving static files faster
    • Checking the user's permissions before every page
    • Being the application's single entry point: every request is rewritten to it, then delegated to the router