Módulo 19: Arquitectura MVC

Objetivos del módulo

  • Reconocer el problema del código espagueti y por qué hace falta una arquitectura
  • Entender el patrón Modelo-Vista-Controlador y el flujo de una petición
  • Montar un front controller con reescritura de URLs (Apache .htaccess y servidor integrado)
  • Construir un router propio con rutas GET/POST, parámetros y controladores [Clase::class, 'metodo']
  • Escribir controladores, vistas con plantillas PHP y layout (con escape de salida) y modelos/repositorios con PDO
  • Crear un contenedor de dependencias mínimo con autowiring
  • Configurar la aplicación con .env (vlucas/phpdotenv)
  • Organizar el proyecto con una estructura de carpetas profesional y autoload PSR-4
  • Gestionar los errores 404 y 500 de forma centralizada
  • Construir paso a paso un mini-framework completo
  • Conocer qué aportan los frameworks reales: Laravel y Symfony

1. El problema: código espagueti

Mira este fichero. Es el típico productos.php de un proyecto que ha ido creciendo:

<?php
// productos.php  ❌ todo mezclado
session_start();
$pdo = new PDO('mysql:host=localhost;dbname=tienda', 'root', '');

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['borrar'])) {
    $pdo->prepare('DELETE FROM productos WHERE id = ?')->execute([$_POST['id']]);
    header('Location: productos.php');
    exit;
}

$q = $_GET['q'] ?? '';
$stmt = $pdo->prepare('SELECT * FROM productos WHERE nombre LIKE ?');
$stmt->execute(["%$q%"]);
?>
<html>
<head><title>Productos</title></head>
<body>
<?php include 'menu.php'; ?>
<h1>Productos</h1>
<?php foreach ($stmt as $p): ?>
    <?php if ($p['stock'] < 5) { $clase = 'rojo'; } else { $clase = ''; } ?>
    <p class="<?= $clase ?>"><?= htmlspecialchars($p['nombre']) ?> - <?= $p['precio'] ?> €
    <form method="post"><input type="hidden" name="id" value="<?= $p['id'] ?>">
    <button name="borrar">Borrar</button></form></p>
<?php endforeach; ?>
</body>
</html>

Funciona, pero en un mismo fichero hay conexión a la base de datos, credenciales, lógica de negocio, SQL, gestión de peticiones y HTML. Ahora multiplícalo por 50 páginas:

Problema Consecuencia
La conexión y las credenciales están repetidas en cada fichero Cambiar la contraseña implica editar 50 ficheros
El SQL está mezclado con el HTML El diseñador no puede tocar la plantilla sin riesgo de romper la consulta
La regla “stock bajo = menos de 5” está escrita en la vista Si cambia, hay que buscarla por todas las páginas
Cada fichero es una URL (productos.php?accion=borrar&id=3) URLs feas y lógica de enrutado repartida
No se puede probar nada sin un navegador Imposible escribir tests unitarios (módulo 18)

📘 Concepto: El código espagueti es código en el que todo está entrelazado, como un plato de espaguetis: tiras de un hilo y se mueve todo. La solución es separar responsabilidades: cada parte del código se ocupa de una sola cosa.


2. El patrón MVC

📘 Concepto: MVC (Model-View-Controller) divide la aplicación en tres capas:

  • Modelo: los datos y las reglas de negocio. Sabe qué es un producto, cómo se guarda y qué significa “stock bajo”. No sabe nada de HTML ni de HTTP.
  • Vista: la presentación. Recibe datos ya preparados y genera el HTML. No hace consultas ni toma decisiones de negocio.
  • Controlador: el coordinador. Recibe la petición, pide al modelo lo que necesita y elige qué vista mostrar con qué datos.

Analogía: un restaurante. El camarero (controlador) toma tu pedido y se lo pasa a la cocina (modelo), que sabe preparar los platos y tiene los ingredientes. Cuando el plato está listo, el camarero lo lleva a la mesa con una presentación (vista) cuidada. El cocinero no sale a la sala y el camarero no cocina.

El flujo de una petición

sequenceDiagram
    autonumber
    participant N as Navegador
    participant FC as Front controller<br/>(public/index.php)
    participant R as Router
    participant C as Controlador
    participant M as Modelo / Repositorio
    participant DB as Base de datos
    participant V as Vista

    N->>FC: GET /productos/5
    FC->>R: dispatch(Request)
    R->>C: ProductoController::show(id: 5)
    C->>M: buscarPorId(5)
    M->>DB: SELECT ... WHERE id = 5
    DB-->>M: fila
    M-->>C: objeto Producto
    C->>V: render('productos/show', producto)
    V-->>C: HTML
    C-->>FC: Response (200, HTML)
    FC-->>N: Respuesta HTTP
Capa Responsabilidad Ejemplo en nuestro proyecto
Front controller Punto de entrada único, arranque, errores globales public/index.php
Router Decidir qué controlador atiende cada URL src/Core/Router.php
Controlador Coordinar petición → modelo → vista src/Controller/ProductoController.php
Modelo Entidades y acceso a datos src/Model/Producto.php, ProductoRepository.php
Vista HTML views/productos/index.php

3. Estructura de carpetas y autoload PSR-4

Vamos a construir un mini-framework llamado MiniMVC con esta estructura:

mini-mvc/
├── composer.json
├── .env                  ← configuración local (NO va a Git)
├── .env.example          ← plantilla de configuración (sí va a Git)
├── config/
│   ├── container.php     ← registro de servicios
│   └── routes.php        ← tabla de rutas
├── public/               ← ÚNICA carpeta accesible desde la web
│   ├── index.php         ← front controller
│   ├── .htaccess
│   └── css/app.css
├── src/                  ← namespace App\
│   ├── Core/             ← el "framework"
│   │   ├── Container.php
│   │   ├── Controller.php
│   │   ├── HttpException.php
│   │   ├── Request.php
│   │   ├── Response.php
│   │   ├── Router.php
│   │   └── View.php
│   ├── Controller/       ← controladores de la aplicación
│   │   ├── HomeController.php
│   │   └── ProductoController.php
│   ├── Model/            ← entidades y repositorios
│   │   ├── Producto.php
│   │   └── ProductoRepository.php
│   └── helpers.php       ← funciones globales (e())
├── views/                ← plantillas
│   ├── layout.php
│   ├── home.php
│   ├── errors/error.php
│   └── productos/
│       ├── index.php
│       ├── show.php
│       └── form.php
├── var/log/              ← logs (NO va a Git)
└── vendor/

⚠️ Importante: La clave de seguridad de esta estructura es que el servidor web solo apunta a public/. El código (src/), la configuración (.env), las vistas y las dependencias quedan fuera del alcance de cualquier URL.

composer.json:

{
    "name": "curso/mini-mvc",
    "type": "project",
    "require": {
        "php": ">=8.5",
        "ext-pdo": "*",
        "vlucas/phpdotenv": "^5.6"
    },
    "autoload": {
        "psr-4": { "App\\": "src/" },
        "files": ["src/helpers.php"]
    }
}
composer install

📘 Concepto: Con PSR-4, la clase App\Controller\ProductoController se busca automáticamente en src/Controller/ProductoController.php (módulo 13). La entrada files carga helpers.php en cada petición, para tener disponibles funciones globales como e().

<?php
declare(strict_types=1);

// src/helpers.php

/** Escapa texto para HTML (módulo 16) */
function e(string|int|float|null $valor): string
{
    return htmlspecialchars((string) $valor, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

4. Front controller y reescritura de URLs

Queremos URLs limpias como /productos/5/editar en lugar de editar.php?id=5. Para eso todas las peticiones (salvo las de ficheros que existen, como CSS o imágenes) deben llegar a public/index.php.

Apache: .htaccess

# public/.htaccess
<IfModule mod_rewrite.c>
    RewriteEngine On

    # Si el fichero o la carpeta existen (css, js, imágenes), se sirven tal cual
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteCond %{REQUEST_FILENAME} !-d

    # Todo lo demás va al front controller, conservando la query string
    RewriteRule ^ index.php [QSA,L]
</IfModule>

💡 Consejo: En XAMPP o Laragon, configura un virtual host cuyo DocumentRoot sea la carpeta public/ del proyecto (Laragon lo hace automáticamente si la carpeta se llama public). Si el .htaccess no hace efecto, comprueba que mod_rewrite está activo y que el directorio tiene AllowOverride All.

Nginx

location / {
    try_files $uri $uri/ /index.php?$query_string;
}

Servidor integrado de PHP

El servidor integrado no lee .htaccess, pero acepta un router script. Usaremos el propio index.php:

php -S localhost:8000 -t public public/index.php

Y al principio de index.php dejamos pasar los ficheros estáticos:

<?php
// Solo con el servidor integrado: si el fichero existe (css, imágenes), que lo sirva él
if (PHP_SAPI === 'cli-server') {
    $fichero = __DIR__ . parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
    if (is_file($fichero)) {
        return false;
    }
}

5. Petición y respuesta: Request y Response

En lugar de leer $_GET, $_POST y $_SERVER desde cualquier sitio, los encapsulamos en un objeto. Y en lugar de hacer echo y header() en mitad del código, los controladores devuelven un objeto Response. Esto hace el código más claro y, sobre todo, testeable.

<?php
declare(strict_types=1);

// src/Core/Request.php
namespace App\Core;

final readonly class Request
{
    public function __construct(
        public string $method,
        public string $path,
        public array $query = [],
        public array $body = [],
        public array $files = [],
        public array $server = [],
    ) {}

    public static function fromGlobals(): self
    {
        $method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');

        // Los formularios HTML solo admiten GET y POST: permitimos "simular" otros métodos
        if ($method === 'POST' && isset($_POST['_method'])) {
            $simulado = strtoupper((string) $_POST['_method']);
            if (in_array($simulado, ['PUT', 'PATCH', 'DELETE'], true)) {
                $method = $simulado;
            }
        }

        $path = rawurldecode(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/');
        $path = '/' . trim($path, '/');   // "/productos/" y "/productos" son la misma ruta

        return new self($method, $path, $_GET, $_POST, $_FILES, $_SERVER);
    }

    /** Un dato del cuerpo (POST) o de la query string (GET) */
    public function input(string $clave, mixed $porDefecto = null): mixed
    {
        return $this->body[$clave] ?? $this->query[$clave] ?? $porDefecto;
    }

    public function isPost(): bool
    {
        return $this->method === 'POST';
    }
}
<?php
declare(strict_types=1);

// src/Core/Response.php
namespace App\Core;

final class Response
{
    public function __construct(
        private string $body = '',
        private int $status = 200,
        private array $headers = ['Content-Type' => 'text/html; charset=utf-8'],
    ) {}

    public static function redirect(string $url, int $status = 303): self
    {
        return new self('', $status, ['Location' => $url]);
    }

    public static function json(mixed $datos, int $status = 200): self
    {
        $json = json_encode($datos, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
        return new self($json, $status, ['Content-Type' => 'application/json; charset=utf-8']);
    }

    public function withHeader(string $nombre, string $valor): self
    {
        $copia = clone $this;
        $copia->headers[$nombre] = $valor;
        return $copia;
    }

    public function status(): int
    {
        return $this->status;
    }

    public function body(): string
    {
        return $this->body;
    }

    public function send(): void
    {
        http_response_code($this->status);
        foreach ($this->headers as $nombre => $valor) {
            header("$nombre: $valor");
        }
        echo $this->body;
    }
}

6. El router

El router guarda una tabla de rutas (método + patrón de URL + quién la atiende) y, para cada petición, busca la que coincide.

Queremos poder escribir rutas así:

$router->get('/', [HomeController::class, 'index']);
$router->get('/productos/{id:\d+}', [ProductoController::class, 'show']);
$router->post('/productos', [ProductoController::class, 'store']);
$router->get('/hola/{nombre}', fn(Request $r, string $nombre) => new Response('Hola, ' . e($nombre)));
  • {id} es un parámetro: cualquier texto sin /.
  • {id:\d+} es un parámetro con restricción: solo dígitos.
  • El handler puede ser una closure o un array [Controlador::class, 'metodo'].

Primero, la excepción que usaremos para los errores HTTP:

<?php
declare(strict_types=1);

// src/Core/HttpException.php
namespace App\Core;

final class HttpException extends \RuntimeException
{
    public function __construct(
        public readonly int $status,
        string $mensaje = '',
        public readonly array $permitidos = [],   // Métodos permitidos (para el 405)
    ) {
        parent::__construct($mensaje, $status);
    }

    public static function notFound(string $mensaje = 'Página no encontrada'): self
    {
        return new self(404, $mensaje);
    }
}
<?php
declare(strict_types=1);

// src/Core/Router.php
namespace App\Core;

final class Router
{
    /** @var list<array{0: string, 1: string, 2: \Closure|array}> */
    private array $rutas = [];

    public function __construct(private Container $container) {}

    public function get(string $patron, \Closure|array $handler): void
    {
        $this->add('GET', $patron, $handler);
    }

    public function post(string $patron, \Closure|array $handler): void
    {
        $this->add('POST', $patron, $handler);
    }

    public function add(string $metodo, string $patron, \Closure|array $handler): void
    {
        $this->rutas[] = [strtoupper($metodo), $this->compilar($patron), $handler];
    }

    /**
     * Convierte "/productos/{id:\d+}" en la expresión regular "#^/productos/(?P<id>\d+)$#"
     */
    private function compilar(string $patron): string
    {
        $regex = preg_replace_callback(
            '#\{(\w+)(?::([^}]+))?\}#',
            fn(array $m): string => '(?P<' . $m[1] . '>' . ($m[2] ?? '[^/]+') . ')',
            $patron,
        );
        return '#^' . $regex . '$#';
    }

    public function dispatch(Request $request): Response
    {
        $permitidos = [];

        foreach ($this->rutas as [$metodo, $regex, $handler]) {
            if (!preg_match($regex, $request->path, $coincidencias)) {
                continue;
            }
            if ($metodo !== $request->method) {
                $permitidos[] = $metodo;      // La URL existe, pero con otro método
                continue;
            }

            // Solo los grupos con nombre, y los numéricos convertidos a int
            $parametros = array_filter($coincidencias, 'is_string', ARRAY_FILTER_USE_KEY);
            $parametros = array_map(fn(string $v) => ctype_digit($v) ? (int) $v : $v, $parametros);

            return $this->ejecutar($handler, $request, $parametros);
        }

        if ($permitidos !== []) {
            throw new HttpException(405, 'Método no permitido', $permitidos);
        }
        throw HttpException::notFound();
    }

    private function ejecutar(\Closure|array $handler, Request $request, array $parametros): Response
    {
        if (is_array($handler)) {
            [$clase, $metodo] = $handler;
            // El contenedor crea el controlador con sus dependencias (sección 10)
            $handler = $this->container->get($clase)->$metodo(...);
        }

        // Los parámetros de la ruta se pasan como argumentos con nombre: show(Request $r, int $id)
        $resultado = $handler($request, ...$parametros);

        return $resultado instanceof Response ? $resultado : new Response((string) $resultado);
    }
}

📘 Concepto: Fíjate en $handler($request, ...$parametros). Como $parametros tiene claves de texto (['id' => 5]), el operador ... las pasa como argumentos con nombre (PHP 8.1). Así el controlador recibe int $id aunque tenga otros parámetros, sin importar el orden. Y $objeto->$metodo(...) crea una first-class callable a partir del método.

💡 Consejo: El orden de las rutas importa: se usa la primera que coincide. Si tienes /productos/{slug} y /productos/nuevo, declara antes la fija (/nuevo), o usa restricciones como {id:\d+} para que no se solapen.

Las rutas de la aplicación

<?php
declare(strict_types=1);

// config/routes.php
use App\Controller\HomeController;
use App\Controller\ProductoController;
use App\Core\{Request, Response, Router};

return function (Router $r): void {
    $r->get('/', [HomeController::class, 'index']);

    $r->get('/productos',                    [ProductoController::class, 'index']);
    $r->get('/productos/nuevo',              [ProductoController::class, 'create']);
    $r->post('/productos',                   [ProductoController::class, 'store']);
    $r->get('/productos/{id:\d+}',           [ProductoController::class, 'show']);
    $r->get('/productos/{id:\d+}/editar',    [ProductoController::class, 'edit']);
    $r->post('/productos/{id:\d+}',          [ProductoController::class, 'update']);
    $r->post('/productos/{id:\d+}/borrar',   [ProductoController::class, 'destroy']);

    // Una ruta con closure, para casos muy simples
    $r->get('/hola/{nombre}', fn(Request $req, string $nombre): Response => new Response('Hola, ' . e($nombre)));
};

7. Controladores

Un controlador es una clase cuyos métodos (llamados acciones) reciben la petición y devuelven una respuesta. Creamos una clase base con utilidades comunes:

<?php
declare(strict_types=1);

// src/Core/Controller.php
namespace App\Core;

abstract class Controller
{
    public function __construct(protected View $view) {}

    protected function render(string $plantilla, array $datos = [], int $status = 200): Response
    {
        return new Response($this->view->render($plantilla, $datos), $status);
    }

    protected function redirect(string $url): Response
    {
        return Response::redirect($url);
    }
}
<?php
declare(strict_types=1);

// src/Controller/HomeController.php
namespace App\Controller;

use App\Core\{Controller, Request, Response};

final class HomeController extends Controller
{
    public function index(Request $request): Response
    {
        return $this->render('home', [
            'titulo' => 'Inicio',
            'hora'   => new \DateTimeImmutable(),
        ]);
    }
}

El controlador de productos, con el CRUD completo:

<?php
declare(strict_types=1);

// src/Controller/ProductoController.php
namespace App\Controller;

use App\Core\{Controller, HttpException, Request, Response, View};
use App\Model\{Producto, ProductoRepository};

final class ProductoController extends Controller
{
    public function __construct(View $view, private ProductoRepository $productos)
    {
        parent::__construct($view);
    }

    public function index(Request $request): Response
    {
        $q = mb_trim((string) $request->input('q', ''));

        return $this->render('productos/index', [
            'titulo'    => 'Productos',
            'q'         => $q,
            'productos' => $this->productos->buscar($q),
        ]);
    }

    public function show(Request $request, int $id): Response
    {
        $producto = $this->productos->buscarPorId($id) ?? throw HttpException::notFound('Producto no encontrado');

        return $this->render('productos/show', ['titulo' => $producto->nombre, 'producto' => $producto]);
    }

    public function create(Request $request): Response
    {
        return $this->render('productos/form', [
            'titulo'  => 'Nuevo producto',
            'accion'  => '/productos',
            'datos'   => ['nombre' => '', 'descripcion' => '', 'precio' => '', 'stock' => '0'],
            'errores' => [],
        ]);
    }

    public function store(Request $request): Response
    {
        [$datos, $errores] = $this->validar($request->body);

        if ($errores !== []) {
            return $this->render('productos/form', [
                'titulo' => 'Nuevo producto', 'accion' => '/productos', 'datos' => $datos, 'errores' => $errores,
            ], 422);
        }

        $producto = $this->productos->guardar(Producto::desdeFormulario($datos));
        return $this->redirect("/productos/{$producto->id}");
    }

    public function edit(Request $request, int $id): Response
    {
        $producto = $this->productos->buscarPorId($id) ?? throw HttpException::notFound();

        return $this->render('productos/form', [
            'titulo'  => "Editar: {$producto->nombre}",
            'accion'  => "/productos/$id",
            'datos'   => $producto->aArray(),
            'errores' => [],
        ]);
    }

    public function update(Request $request, int $id): Response
    {
        $this->productos->buscarPorId($id) ?? throw HttpException::notFound();
        [$datos, $errores] = $this->validar($request->body);

        if ($errores !== []) {
            return $this->render('productos/form', [
                'titulo' => 'Editar producto', 'accion' => "/productos/$id", 'datos' => $datos, 'errores' => $errores,
            ], 422);
        }

        $this->productos->guardar(Producto::desdeFormulario($datos, $id));
        return $this->redirect("/productos/$id");
    }

    public function destroy(Request $request, int $id): Response
    {
        $this->productos->borrar($id);
        return $this->redirect('/productos');
    }

    /** @return array{0: array, 1: array<string, string>} */
    private function validar(array $entrada): array
    {
        $datos = [
            'nombre'      => mb_trim((string) ($entrada['nombre'] ?? '')),
            'descripcion' => mb_trim((string) ($entrada['descripcion'] ?? '')),
            'precio'      => (string) ($entrada['precio'] ?? ''),
            'stock'       => (string) ($entrada['stock'] ?? ''),
        ];
        $errores = [];

        if ($datos['nombre'] === '' || mb_strlen($datos['nombre']) > 150) {
            $errores['nombre'] = 'El nombre es obligatorio (máximo 150 caracteres).';
        }
        if (filter_var($datos['precio'], FILTER_VALIDATE_FLOAT) === false || (float) $datos['precio'] < 0) {
            $errores['precio'] = 'Introduce un precio válido.';
        }
        if (filter_var($datos['stock'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]) === false) {
            $errores['stock'] = 'El stock debe ser un entero mayor o igual que 0.';
        }

        return [$datos, $errores];
    }
}

💡 Consejo: Un controlador debe ser delgado: recibe, valida, delega y responde. Si una acción crece mucho (cálculos, varias consultas, envío de emails), saca esa lógica a una clase de servicio (por ejemplo, ServicioPedidos, como en el módulo 18) y llámala desde el controlador.

⚠️ Importante: A estos formularios les falta la protección CSRF del módulo 16. La añadiremos como un servicio del framework en el proyecto final. No publiques una aplicación sin ella.


8. Vistas con plantillas PHP y layout

PHP es en sí mismo un lenguaje de plantillas. La clase View busca un fichero en views/, le pasa las variables y captura el HTML generado con output buffering. Después, lo inserta dentro de un layout: la estructura común (cabecera, menú, pie) que comparten todas las páginas.

<?php
declare(strict_types=1);

// src/Core/View.php
namespace App\Core;

final class View
{
    public function __construct(private string $directorio) {}

    public function render(string $plantilla, array $datos = [], ?string $layout = 'layout'): string
    {
        $contenido = $this->renderizarFichero($plantilla, $datos);

        if ($layout === null) {
            return $contenido;
        }
        return $this->renderizarFichero($layout, [...$datos, 'contenido' => $contenido]);
    }

    private function renderizarFichero(string $plantilla, array $datos): string
    {
        $fichero = $this->directorio . '/' . $plantilla . '.php';
        if (!is_file($fichero)) {
            throw new \RuntimeException("No existe la vista $plantilla");
        }

        extract($datos, EXTR_SKIP);   // ['titulo' => 'X'] se convierte en la variable $titulo
        ob_start();                   // Todo lo que se imprima a partir de aquí se guarda en un búfer
        try {
            require $fichero;
            return (string) ob_get_clean();
        } catch (\Throwable $e) {
            ob_end_clean();
            throw $e;
        }
    }
}

El layout

<?php /** @var string $titulo  @var string $contenido */ ?>
<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title><?= e($titulo ?? 'MiniMVC') ?> · MiniMVC</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css">
    <link rel="stylesheet" href="/css/app.css">
</head>
<body>
    <nav class="navbar navbar-expand bg-dark navbar-dark mb-4">
        <div class="container">
            <a class="navbar-brand" href="/">MiniMVC</a>
            <ul class="navbar-nav">
                <li class="nav-item"><a class="nav-link" href="/productos">Productos</a></li>
                <li class="nav-item"><a class="nav-link" href="/productos/nuevo">Nuevo</a></li>
            </ul>
        </div>
    </nav>

    <main class="container">
        <?= $contenido /* HTML ya generado por la vista: NO se escapa */ ?>
    </main>

    <footer class="container text-muted small py-4">&copy; <?= date('Y') ?> MiniMVC</footer>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

La vista de inicio

<?php
// views/home.php
/** @var DateTimeImmutable $hora */
?>
<div class="p-5 bg-light rounded-3">
    <h1 class="display-5">Bienvenido a MiniMVC</h1>
    <p class="lead">Un mini-framework construido paso a paso con PHP 8.5.</p>
    <p class="text-muted">Página generada a las <?= e($hora->format('H:i:s')) ?>.</p>
    <a href="/productos" class="btn btn-primary">Ver productos</a>
</div>

Las vistas de productos

<?php
// views/productos/index.php
/** @var string $q  @var list<App\Model\Producto> $productos */
?>
<div class="d-flex justify-content-between align-items-center mb-3">
    <h1><?= e($titulo) ?></h1>
    <a href="/productos/nuevo" class="btn btn-primary">Nuevo producto</a>
</div>

<form method="get" action="/productos" class="d-flex gap-2 mb-3">
    <input class="form-control" type="search" name="q" value="<?= e($q) ?>" placeholder="Buscar...">
    <button class="btn btn-outline-secondary">Buscar</button>
</form>

<?php if ($productos === []): ?>
    <p class="alert alert-info">No hay productos<?= $q !== '' ? ' que coincidan con «' . e($q) . '»' : '' ?>.</p>
<?php else: ?>
    <table class="table table-hover align-middle">
        <thead><tr><th>Nombre</th><th class="text-end">Precio</th><th class="text-end">Stock</th></tr></thead>
        <tbody>
        <?php foreach ($productos as $p): ?>
            <tr class="<?= $p->stockBajo() ? 'table-warning' : '' ?>">
                <td><a href="/productos/<?= $p->id ?>"><?= e($p->nombre) ?></a></td>
                <td class="text-end"><?= e($p->precioFormateado()) ?></td>
                <td class="text-end"><?= $p->stock ?></td>
            </tr>
        <?php endforeach; ?>
        </tbody>
    </table>
<?php endif; ?>
<?php
// views/productos/show.php
/** @var App\Model\Producto $producto */
?>
<h1><?= e($producto->nombre) ?></h1>
<p class="lead"><?= e($producto->precioFormateado()) ?> · <?= $producto->stock ?> en stock</p>
<p><?= nl2br(e($producto->descripcion ?? 'Sin descripción')) ?></p>

<div class="d-flex gap-2">
    <a href="/productos/<?= $producto->id ?>/editar" class="btn btn-outline-primary">Editar</a>
    <form method="post" action="/productos/<?= $producto->id ?>/borrar"
          onsubmit="return confirm('¿Borrar este producto?')">
        <button class="btn btn-outline-danger">Borrar</button>
    </form>
    <a href="/productos" class="btn btn-link">Volver al listado</a>
</div>
<?php
// views/productos/form.php  — se usa para crear Y para editar
/** @var string $accion  @var array $datos  @var array<string,string> $errores */
?>
<h1><?= e($titulo) ?></h1>

<form method="post" action="<?= e($accion) ?>" novalidate>
    <div class="mb-3">
        <label class="form-label" for="nombre">Nombre</label>
        <input class="form-control <?= isset($errores['nombre']) ? 'is-invalid' : '' ?>"
               id="nombre" name="nombre" value="<?= e($datos['nombre']) ?>">
        <div class="invalid-feedback"><?= e($errores['nombre'] ?? '') ?></div>
    </div>
    <div class="mb-3">
        <label class="form-label" for="descripcion">Descripción</label>
        <textarea class="form-control" id="descripcion" name="descripcion" rows="3"><?= e($datos['descripcion'] ?? '') ?></textarea>
    </div>
    <div class="row">
        <div class="col mb-3">
            <label class="form-label" for="precio">Precio (€)</label>
            <input class="form-control <?= isset($errores['precio']) ? 'is-invalid' : '' ?>"
                   id="precio" name="precio" inputmode="decimal" value="<?= e($datos['precio']) ?>">
            <div class="invalid-feedback"><?= e($errores['precio'] ?? '') ?></div>
        </div>
        <div class="col mb-3">
            <label class="form-label" for="stock">Stock</label>
            <input class="form-control <?= isset($errores['stock']) ? 'is-invalid' : '' ?>"
                   id="stock" name="stock" type="number" min="0" value="<?= e($datos['stock']) ?>">
            <div class="invalid-feedback"><?= e($errores['stock'] ?? '') ?></div>
        </div>
    </div>
    <button class="btn btn-primary">Guardar</button>
    <a href="/productos" class="btn btn-link">Cancelar</a>
</form>

⚠️ Importante: En las vistas, todo dato dinámico se imprime con e(). La única excepción es $contenido en el layout, porque ya es HTML generado (y escapado) por otra vista. Si alguna vez dudas, escapa.

💡 Consejo: Reglas para unas vistas limpias: solo if, foreach y echo (con la sintaxis alternativa : ... endforeach;), nada de SQL ni de lógica de negocio. Si una vista necesita un cálculo (como “¿stock bajo?”), pídeselo al modelo: $p->stockBajo(). Motores como Twig (Symfony) o Blade (Laravel) imponen estas reglas y escapan automáticamente.


9. Modelos y repositorios con PDO

La entidad

<?php
declare(strict_types=1);

// src/Model/Producto.php
namespace App\Model;

final readonly class Producto
{
    public const int UMBRAL_STOCK_BAJO = 5;

    public function __construct(
        public ?int $id,
        public string $nombre,
        public float $precio,
        public int $stock,
        public ?string $descripcion = null,
    ) {}

    public static function desdeFila(array $fila): self
    {
        return new self(
            id: (int) $fila['id'],
            nombre: $fila['nombre'],
            precio: (float) $fila['precio'],
            stock: (int) $fila['stock'],
            descripcion: $fila['descripcion'],
        );
    }

    public static function desdeFormulario(array $datos, ?int $id = null): self
    {
        return new self(
            id: $id,
            nombre: $datos['nombre'],
            precio: (float) $datos['precio'],
            stock: (int) $datos['stock'],
            descripcion: $datos['descripcion'] !== '' ? $datos['descripcion'] : null,
        );
    }

    // Reglas de negocio: viven en el modelo, no en la vista
    public function stockBajo(): bool
    {
        return $this->stock < self::UMBRAL_STOCK_BAJO;
    }

    public function precioFormateado(): string
    {
        return number_format($this->precio, 2, ',', '.') . ' €';
    }

    public function aArray(): array
    {
        return [
            'nombre' => $this->nombre, 'descripcion' => $this->descripcion ?? '',
            'precio' => (string) $this->precio, 'stock' => (string) $this->stock,
        ];
    }

    /** Copia del producto con el id asignado ("clone with" de PHP 8.5) */
    public function conId(int $id): static
    {
        return clone($this, ['id' => $id]);
    }
}

📘 Concepto: clone($this, ['id' => $id]) es la sintaxis “clone with” de PHP 8.5: crea una copia del objeto modificando algunas propiedades, incluso si son readonly. Es la forma idiomática de “cambiar” objetos inmutables (los clásicos métodos with...()). Las asignaciones respetan la visibilidad: como las propiedades readonly solo se pueden escribir desde dentro de la clase, el clone() va en un método de Producto y no en el repositorio.

El repositorio

<?php
declare(strict_types=1);

// src/Model/ProductoRepository.php
namespace App\Model;

use PDO;

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

    /** @return list<Producto> */
    public function buscar(string $texto = ''): array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM productos WHERE nombre LIKE ? ORDER BY nombre');
        $stmt->execute(['%' . addcslashes($texto, '%_\\') . '%']);
        return array_map(Producto::desdeFila(...), $stmt->fetchAll());
    }

    public function buscarPorId(int $id): ?Producto
    {
        $stmt = $this->pdo->prepare('SELECT * FROM productos WHERE id = ?');
        $stmt->execute([$id]);
        $fila = $stmt->fetch();
        return $fila ? Producto::desdeFila($fila) : null;
    }

    public function guardar(Producto $p): Producto
    {
        $datos = [
            'nombre' => $p->nombre, 'descripcion' => $p->descripcion,
            'precio' => $p->precio, 'stock' => $p->stock,
        ];

        if ($p->id === null) {
            $this->pdo->prepare(
                'INSERT INTO productos (nombre, descripcion, precio, stock)
                 VALUES (:nombre, :descripcion, :precio, :stock)'
            )->execute($datos);

            // Devolvemos una copia del producto inmutable, ya con su id
            return $p->conId((int) $this->pdo->lastInsertId());
        }

        $this->pdo->prepare(
            'UPDATE productos SET nombre = :nombre, descripcion = :descripcion,
             precio = :precio, stock = :stock WHERE id = :id'
        )->execute([...$datos, 'id' => $p->id]);

        return $p;
    }

    public function borrar(int $id): void
    {
        $this->pdo->prepare('DELETE FROM productos WHERE id = ?')->execute([$id]);
    }
}

💡 Consejo: Fíjate en que el repositorio recibe y devuelve objetos Producto, no arrays. El resto de la aplicación (controladores y vistas) trabaja con objetos con métodos útiles (stockBajo(), precioFormateado()) y no necesita conocer los nombres de las columnas de la tabla.

Script SQL del ejemplo (MySQL/MariaDB):

CREATE DATABASE IF NOT EXISTS mini_mvc CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE mini_mvc;

CREATE TABLE productos (
    id          INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    nombre      VARCHAR(150) NOT NULL,
    descripcion TEXT NULL,
    precio      DECIMAL(10,2) NOT NULL,
    stock       INT UNSIGNED NOT NULL DEFAULT 0
);

INSERT INTO productos (nombre, descripcion, precio, stock) VALUES
('Teclado mecánico', 'Switches marrones', 79.90, 15),
('Ratón inalámbrico', NULL, 34.50, 3),
('Monitor 27"', 'QHD 165 Hz', 289.00, 8);

10. Contenedor de dependencias

El ProductoController necesita una View y un ProductoRepository; el repositorio necesita un PDO; el PDO necesita la configuración… ¿Quién crea todo eso y en qué orden?

📘 Concepto: Un contenedor de dependencias (o de servicios) es un objeto que sabe cómo construir los servicios de la aplicación y los entrega ya montados. Con autowiring, el contenedor lee los tipos de los parámetros del constructor (con Reflection) y resuelve las dependencias automáticamente. Es exactamente lo que hacen Symfony y Laravel, a mayor escala.

<?php
declare(strict_types=1);

// src/Core/Container.php
namespace App\Core;

final class Container
{
    /** @var array<string, \Closure> */
    private array $fabricas = [];

    /** @var array<string, object> */
    private array $instancias = [];

    /** Registra cómo se crea un servicio */
    public function set(string $id, \Closure $fabrica): void
    {
        $this->fabricas[$id] = $fabrica;
    }

    /** Devuelve el servicio (siempre la misma instancia) */
    public function get(string $id): object
    {
        if (isset($this->instancias[$id])) {
            return $this->instancias[$id];
        }

        $servicio = isset($this->fabricas[$id])
            ? ($this->fabricas[$id])($this)
            : $this->autowire($id);

        return $this->instancias[$id] = $servicio;
    }

    /** Crea una clase resolviendo su constructor automáticamente */
    private function autowire(string $clase): object
    {
        if (!class_exists($clase)) {
            throw new \LogicException("El contenedor no sabe crear «{$clase}»");
        }

        $reflexion = new \ReflectionClass($clase);
        $constructor = $reflexion->getConstructor();

        if ($constructor === null) {
            return new $clase();
        }

        $argumentos = [];
        foreach ($constructor->getParameters() as $parametro) {
            $tipo = $parametro->getType();

            if ($tipo instanceof \ReflectionNamedType && !$tipo->isBuiltin()) {
                $argumentos[] = $this->get($tipo->getName());   // Recursivo: resuelve sus dependencias
            } elseif ($parametro->isDefaultValueAvailable()) {
                $argumentos[] = $parametro->getDefaultValue();
            } else {
                throw new \LogicException("No se puede resolver \${$parametro->getName()} en {$clase}");
            }
        }

        return $reflexion->newInstanceArgs($argumentos);
    }
}

El registro de servicios que no se pueden autoconstruir (porque necesitan valores de configuración):

<?php
declare(strict_types=1);

// config/container.php
use App\Core\{Container, View};

$container = new Container();

$container->set(PDO::class, fn(): PDO => new PDO(
    $_ENV['DB_DSN'],
    $_ENV['DB_USER'] ?? null,
    $_ENV['DB_PASS'] ?? null,
    [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES   => false,
    ],
));

$container->set(View::class, fn(): View => new View(dirname(__DIR__) . '/views'));

return $container;

Con esto, $container->get(ProductoController::class):

  1. No hay fábrica registrada → autowiring de ProductoController.
  2. Su constructor pide View → hay fábrica → la crea.
  3. Pide ProductoRepository → autowiring → su constructor pide PDO → hay fábrica → conecta.
  4. Devuelve el controlador listo para usar.

💡 Consejo: El PDO solo se crea cuando alguien lo pide. La página de inicio, que no usa la base de datos, no abre ninguna conexión.


11. Configuración con .env

Usamos la librería vlucas/phpdotenv, que lee el fichero .env y carga sus valores en $_ENV y $_SERVER.

# .env  (en .gitignore)
APP_ENV=dev
APP_DEBUG=true
DB_DSN="mysql:host=127.0.0.1;port=3306;dbname=mini_mvc;charset=utf8mb4"
DB_USER=mini_mvc
DB_PASS=cambia-esta-clave
# .env.example  (en Git)
APP_ENV=prod
APP_DEBUG=false
DB_DSN="mysql:host=127.0.0.1;port=3306;dbname=mini_mvc;charset=utf8mb4"
DB_USER=
DB_PASS=
<?php
// En public/index.php
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
$dotenv->safeLoad();                              // No falla si no hay .env (en producción pueden venir del servidor)
$dotenv->required(['APP_ENV', 'DB_DSN']);         // Pero estas variables deben existir
$dotenv->required('APP_DEBUG')->isBoolean();
Método Comportamiento
load() Carga .env; lanza excepción si no existe
safeLoad() Carga .env si existe; si no, no hace nada
required([...]) Lanza excepción si falta alguna variable
->notEmpty(), ->isInteger(), ->isBoolean(), ->allowedValues([...]) Validaciones adicionales

💡 Consejo: Con createImmutable, las variables que ya existen en el entorno real (las que define el servidor o Docker) no se sobrescriben con las del fichero. Así, en producción mandan las del servidor y el .env es solo una comodidad para desarrollo.


12. El front controller completo y los errores 404/500

<?php
declare(strict_types=1);

// public/index.php
use App\Core\{HttpException, Request, Response, Router, View};

// 1. Ficheros estáticos con el servidor integrado
if (PHP_SAPI === 'cli-server' && is_file(__DIR__ . parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH))) {
    return false;
}

$raiz = dirname(__DIR__);
require $raiz . '/vendor/autoload.php';

// 2. Configuración
$dotenv = Dotenv\Dotenv::createImmutable($raiz);
$dotenv->safeLoad();
$dotenv->required(['APP_ENV', 'DB_DSN']);
$debug = filter_var($_ENV['APP_DEBUG'] ?? false, FILTER_VALIDATE_BOOL);

// 3. Errores: todo a excepción, nada en pantalla (lo mostramos nosotros)
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
ini_set('error_log', $raiz . '/var/log/php-error.log');
set_error_handler(function (int $nivel, string $msg, string $fichero, int $linea): bool {
    if (!(error_reporting() & $nivel)) {
        return false;
    }
    throw new ErrorException($msg, 0, $nivel, $fichero, $linea);
});

// 4. Servicios y rutas
$container = require $raiz . '/config/container.php';
$router = new Router($container);
(require $raiz . '/config/routes.php')($router);

// 5. Atender la petición
$request = Request::fromGlobals();

try {
    $response = $router->dispatch($request);
} catch (HttpException $e) {
    // 404, 405...: errores "esperados"
    $html = $container->get(View::class)->render('errors/error', [
        'titulo'  => "Error {$e->status}",
        'status'  => $e->status,
        'mensaje' => $e->getMessage(),
    ]);
    $response = new Response($html, $e->status);
    if ($e->status === 405) {
        $response = $response->withHeader('Allow', implode(', ', $e->permitidos));
    }
} catch (Throwable $e) {
    // Cualquier otro error: al log, y página 500
    error_log((string) $e);
    $html = $container->get(View::class)->render('errors/error', [
        'titulo'  => 'Error 500',
        'status'  => 500,
        'mensaje' => 'Ha ocurrido un error inesperado. Inténtalo de nuevo más tarde.',
        'detalle' => $debug ? (string) $e : null,   // Solo en desarrollo
    ]);
    $response = new Response($html, 500);
}

$response->send();

La vista de error:

<?php
// views/errors/error.php
/** @var int $status  @var string $mensaje  @var ?string $detalle */
?>
<div class="text-center py-5">
    <p class="display-1 fw-bold text-secondary"><?= $status ?></p>
    <p class="lead"><?= e($mensaje) ?></p>
    <a href="/" class="btn btn-primary">Volver al inicio</a>
</div>

<?php if (!empty($detalle)): ?>
    <pre class="bg-light border p-3 small"><?= e($detalle) ?></pre>
<?php endif; ?>

⚠️ Importante: Si el error 500 se produce dentro de la propia vista de error o del layout, tendrías un error al gestionar el error. Los frameworks reales tienen una página de último recurso en HTML puro. Puedes envolver el render() del catch (Throwable) en otro try y, si falla, hacer un simple echo '<h1>Error 500</h1>'.


13. El mini-framework paso a paso

Recapitulemos el orden en el que lo hemos construido. Es un buen guion para que lo reconstruyas tú desde cero:

Paso Qué haces Ficheros Cómo comprobarlo
1 Crear la estructura y composer.json con PSR-4 composer.json, carpetas composer dump-autoload sin errores
2 Front controller que imprime “Hola” y reescritura de URLs public/index.php, .htaccess /cualquier/cosa muestra “Hola”
3 Request y Response src/Core/ var_dump(Request::fromGlobals())
4 Router con closures src/Core/Router.php, config/routes.php /hola/Ana saluda; /nada lanza 404
5 View y layout src/Core/View.php, views/ La página de inicio sale con Bootstrap
6 Controladores [Clase::class, 'metodo'] src/Controller/ / lo atiende HomeController
7 Modelo y repositorio con PDO src/Model/ /productos lista desde la BD
8 Contenedor con autowiring src/Core/Container.php, config/container.php Los controladores reciben sus dependencias
9 .env .env, .env.example Cambiar la BD sin tocar código
10 Errores 404/405/500 index.php, views/errors/ Una URL inexistente muestra la página 404

Y este es el viaje completo de la petición POST /productos con un formulario válido:

  1. Apache (o el servidor integrado) envía la petición a public/index.php.
  2. Se carga el autoload, el .env, los manejadores de errores, el contenedor y las rutas.
  3. Request::fromGlobals() empaqueta $_SERVER, $_POST…
  4. El router encuentra POST /productos → [ProductoController::class, 'store'].
  5. El contenedor construye ProductoController con su View y su ProductoRepository (y este, con su PDO).
  6. store() valida, crea un Producto y llama a $productos->guardar().
  7. Devuelve Response::redirect('/productos/9') (303).
  8. index.php llama a $response->send(). El navegador hace GET /productos/9 y el ciclo empieza de nuevo.

14. Frameworks reales: Laravel y Symfony

Acabas de construir, en unos cientos de líneas, las piezas básicas de un framework. Ahora entenderás mucho mejor qué hacen los frameworks profesionales… y por qué en un proyecto real no conviene mantener tu propio framework.

Pieza Nuestro MiniMVC Laravel Symfony
Front controller public/index.php public/index.php public/index.php
Router Array de rutas y regex Route::get('/productos/{id}', ...) Atributo #[Route('/productos/{id}')]
Request/Response Clases propias Illuminate\Http\Request Componente HttpFoundation
Contenedor Autowiring básico Service Container DependencyInjection (compilado, muy rápido)
Vistas PHP + e() Blade ({{ $x }} escapa) Twig ({{ x }} escapa)
Modelo / BD PDO + repositorio Eloquent (Active Record) Doctrine ORM (Data Mapper)
Configuración phpdotenv .env + config/*.php .env + YAML
Validación Manual $request->validate([...]) Componente Validator con atributos
Seguridad Por hacer (CSRF, login) Incluida Componente Security
Consola — php artisan php bin/console
Testing PHPUnit a mano PHPUnit/Pest + utilidades HTTP PHPUnit + WebTestCase

Lo que aportan

  • Seguridad probada: CSRF, autenticación, autorización, hashing, cabeceras… revisadas por miles de desarrolladores.
  • Productividad: generadores de código, migraciones de base de datos, colas, envío de emails, caché, internacionalización.
  • Ecosistema: miles de paquetes compatibles y documentación excelente.
  • Convenciones: cualquier desarrollador que conozca el framework entiende tu proyecto en minutos.
  • Mantenimiento: versiones con soporte a largo plazo (LTS) y parches de seguridad.

El mismo “ver producto” en cada framework

Laravel (versión 13, publicada en marzo de 2026, requiere PHP 8.3 o superior):

<?php
// routes/web.php
Route::get('/productos/{producto}', [ProductoController::class, 'show']);

// app/Http/Controllers/ProductoController.php
class ProductoController extends Controller
{
    // "Route model binding": Laravel busca el Producto por id, o responde 404
    public function show(Producto $producto)
    {
        return view('productos.show', ['producto' => $producto]);
    }
}

Symfony (versión 8.1, publicada en mayo de 2026, requiere PHP 8.4 o superior; la 7.4 es la LTS):

<?php
// src/Controller/ProductoController.php
namespace App\Controller;

use App\Entity\Producto;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class ProductoController extends AbstractController
{
    #[Route('/productos/{id}', name: 'producto_show', methods: ['GET'])]
    public function show(Producto $producto): Response   // Doctrine busca el producto, o 404
    {
        return $this->render('productos/show.html.twig', ['producto' => $producto]);
    }
}
{# templates/productos/show.html.twig #}
{% extends 'base.html.twig' %}

{% block body %}
    <h1>{{ producto.nombre }}</h1>
    <p>{{ producto.precio|number_format(2, ',', '.') }} €</p>
{% endblock %}

💡 Consejo: ¿Cuál elegir? Laravel prioriza la rapidez de desarrollo y una sintaxis muy expresiva; es muy popular en startups y agencias. Symfony prioriza la arquitectura, la flexibilidad y la estabilidad a largo plazo; sus componentes los usan muchos otros proyectos (incluido el propio Laravel, Drupal o Composer). Ambos son excelentes. Si quieres seguir por Symfony, tienes el Curso de Symfony de esta misma serie.


15. Ejercicios

Ejercicio 1: De espagueti a MVC

Toma el productos.php “espagueti” de la sección 1 (o el CRUD del módulo 15) y reescríbelo dentro de MiniMVC. Escribe una lista de qué líneas del fichero original han ido a parar al modelo, cuáles a la vista y cuáles al controlador.

Ejercicio 2: Mejorar el router

Añade al Router: métodos put(), patch() y delete(); nombres de ruta ($r->get('/productos/{id:\d+}', [...])->name('producto.show') o un parámetro name:) y un método url(string $nombre, array $parametros = []): string que genere la URL (url('producto.show', ['id' => 5]) → /productos/5). Usa url() en las vistas en lugar de escribir las rutas a mano.

Ejercicio 3: Tests del router

Escribe tests de PHPUnit para el Router sin servidor web: crea objetos Request a mano (new Request('GET', '/productos/5')) y comprueba que se ejecuta la closure correcta con el parámetro id como entero, que una ruta inexistente lanza HttpException con estado 404 y que un método incorrecto lanza 405 con los métodos permitidos.

Ejercicio 4: Mensajes flash y CSRF como servicios

Crea en src/Core/ una clase Session (envoltorio de $_SESSION con get(), set(), flash() y getFlash()) y una clase Csrf (con token() y validar(Request $r)). Regístralas en el contenedor, muestra los mensajes flash en el layout (“Producto creado”, “Producto borrado”) y verifica el token en todas las acciones POST de ProductoController.

Ejercicio 5: Paginación en el repositorio

Añade paginación al listado de productos: el repositorio devuelve un objeto Pagina (readonly, con elementos, paginaActual, totalPaginas, total) y la vista muestra los enlaces de paginación de Bootstrap conservando el parámetro de búsqueda.

Ejercicio 6: Una API JSON en el mismo framework

Añade rutas /api/productos (GET listado, GET por id, POST) que devuelvan Response::json(). Los errores de las rutas que empiezan por /api/ deben devolverse en formato Problem Details (módulo 17) en lugar de con la página HTML de error.

Ejercicio 7: Comparar con un framework real

Instala Symfony (composer create-project symfony/skeleton productos-symfony) o Laravel (composer create-project laravel/laravel productos-laravel) y recrea únicamente el listado y la ficha de producto. Escribe una tabla comparando, pieza a pieza, dónde está en ese framework cada fichero de MiniMVC.


Resumen

Concepto Clave
Código espagueti Todo mezclado en cada fichero: difícil de mantener y de probar
MVC Modelo (datos y reglas), Vista (HTML), Controlador (coordina)
Front controller public/index.php recibe todas las peticiones
Reescritura Apache RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [QSA,L]
Servidor integrado php -S localhost:8000 -t public public/index.php
Request / Response Objetos en lugar de superglobales y echo sueltos
Ruta con parámetro $r->get('/productos/{id:\d+}', [ProductoController::class, 'show'])
Parámetros al controlador Argumentos con nombre: $handler($request, ...$parametros)
Vista extract() + ob_start() + require + ob_get_clean()
Layout Estructura común con <?= $contenido ?>
Escape e() en todas las variables de las vistas
Repositorio Clase con el SQL de una entidad; recibe PDO
Inmutables (8.5) clone($obj, ['prop' => $valor])
Contenedor set(id, fábrica), get(id) con autowiring por Reflection
Configuración Dotenv::createImmutable($raiz)->safeLoad() + $_ENV
Errores HttpException → página 404/405; Throwable → log + 500
Estructura public/, src/Controller, src/Model, views/, config/, var/
Frameworks Laravel 13 (PHP 8.3+), Symfony 8.1 (PHP 8.4+), Symfony 7.4 LTS