Kodokon kodokon.com

Una arquitectura MVC hecha a mano

Escribe un microframework MVC completo - un enrutador con regex, controladores inyectados, vistas con búfer - para entender lo que los frameworks hacen por ti.

11 min · 3 preguntas

Abrir esta lección en Kodokon

Entender un framework significa saber escribir uno pequeño. Toda arquitectura MVC moderna se apoya en un controlador frontal: un único index.php recibe todas las peticiones (el servidor reescribe cada URL hacia él), y luego un enrutador asocia el método HTTP y la ruta a un manejador. El nuestro convierte patrones como /posts/{id} en expresiones regulares con grupos de captura con nombre: preg_match rellena entonces $found['id'] directamente, sin dividir manualmente la 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';
    }
}
El patrón /posts/{id} se convierte en #^/posts/(?P<id>[^/]+)$#.

El controlador orquesta: lee los parámetros, consulta el modelo - la única capa autorizada a hablar con la base de datos - y elige una vista. Las dependencias entran a través del constructor: esto es inyección de dependencias sin contenedor, y promover propiedades readonly garantiza que nunca cambiarán después de la construcción. El modelo devuelve un null explícito cuando el recurso no existe, en lugar de false: el controlador puede entonces decidir el 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]);
    }
}
La función render se define en el siguiente bloque.

La vista es un simple archivo PHP que solo muestra variables ya preparadas para ella. Dos mecanismos internos lo hacen posible: extract convierte el array de datos en variables locales, y el búfer de salida (ob_start y luego ob_get_clean) captura la salida para devolverla como una cadena en lugar de enviarla de inmediato - indispensable para componer plantillas anidadas o decidir las cabeceras después del renderizado.

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
);
El controlador frontal completo: renderizado, cableado y despacho.

Prueba de conocimientos

Comprueba que has retenido los puntos clave de esta lección.

  1. En este enrutador, ¿qué produce la conversión del patrón /posts/{id}?
    • Una expresión regular con un grupo de captura con nombre, accesible después mediante $found['id']
    • Un array asociativo de los segmentos de la URL
    • Una simple comparación de cadenas, segmento por segmento
  2. ¿Por qué la función render usa ob_start?
    • Para comprimir la salida HTML antes de enviarla
    • Para capturar la salida de la vista y devolverla como una cadena en lugar de enviarla de inmediato al navegador
    • Para impedir que la vista ejecute código PHP
    • Para almacenar en caché el renderizado de la vista en disco
  3. ¿Cuál es el papel del controlador frontal?
    • Servir los archivos estáticos más rápido
    • Comprobar los permisos del usuario antes de cada página
    • Ser el único punto de entrada de la aplicación: cada petición se reescribe hacia él y luego se delega al enrutador