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
.htaccessy 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\ProductoControllerse busca automáticamente ensrc/Controller/ProductoController.php(módulo 13). La entradafilescargahelpers.phpen cada petición, para tener disponibles funciones globales comoe().
<?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
DocumentRootsea la carpetapublic/del proyecto (Laragon lo hace automáticamente si la carpeta se llamapublic). Si el.htaccessno hace efecto, comprueba quemod_rewriteestá activo y que el directorio tieneAllowOverride 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$parametrostiene claves de texto (['id' => 5]), el operador...las pasa como argumentos con nombre (PHP 8.1). Así el controlador recibeint $idaunque 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">© <?= 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$contenidoen 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,foreachyecho(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 sonreadonly. Es la forma idiomática de “cambiar” objetos inmutables (los clásicos métodoswith...()). Las asignaciones respetan la visibilidad: como las propiedadesreadonlysolo se pueden escribir desde dentro de la clase, elclone()va en un método deProductoy 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):
- No hay fábrica registrada → autowiring de
ProductoController. - Su constructor pide
View→ hay fábrica → la crea. - Pide
ProductoRepository→ autowiring → su constructor pidePDO→ hay fábrica → conecta. - Devuelve el controlador listo para usar.
💡 Consejo: El
PDOsolo 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.enves 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()delcatch (Throwable)en otrotryy, si falla, hacer un simpleecho '<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:
- Apache (o el servidor integrado) envía la petición a
public/index.php. - Se carga el autoload, el
.env, los manejadores de errores, el contenedor y las rutas. Request::fromGlobals()empaqueta$_SERVER,$_POST…- El router encuentra
POST /productos→[ProductoController::class, 'store']. - El contenedor construye
ProductoControllercon suViewy suProductoRepository(y este, con suPDO). store()valida, crea unProductoy llama a$productos->guardar().- Devuelve
Response::redirect('/productos/9')(303). index.phpllama a$response->send(). El navegador haceGET /productos/9y 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 |