Laravel · el framework completo
Tercera vuelta al dominio Pedidos: lo que construimos a mano en MVC y Eloquent ahora viene de fábrica — Blade, migraciones, factories, autenticación, API REST con Sanctum, colas y pruebas con Pest, sobre MariaDB real y con las reglas de la casa intactas.
1 · Por qué Laravel y qué resuelve
Básico ~14 minLlevamos dos manuales construyendo un framework sin llamarlo así: el MVC artesanal nos hizo escribir a mano el front controller, las vistas y los repositorios; Eloquent standalone le puso ORM a ese esqueleto. Hoy abrimos la caja que ya trae todas esas piezas ensambladas — más otras tantas que ni habíamos intentado: autenticación, colas, correos, pruebas. No para olvidar lo aprendido, sino para reconocer cada pieza al verla.
- Inventariar qué construimos a mano en MVC + Eloquent y quién lo resuelve aquí.
- Ver el mismo endpoint escrito con nuestro router artesanal y con Laravel.
- Entender «convención sobre configuración» con un ejemplo verificable.
- Fijar qué reglas de la casa sobreviven intactas dentro del framework.
El inventario honesto
Todo esto ya lo dominamos en versión casera. El framework no inventa otra cosa: resuelve EL MISMO problema con piezas mantenidas por miles de personas.
| Pieza | Nuestra versión artesanal | En Laravel |
|---|---|---|
| Front controller + rutas | index.php con switch/regex propio | public/index.php + Router |
| Contenedor de servicios | clase contenedora propia | Service Container (auto-resolución) |
| Vistas | includes + extract + plantillas | Blade: herencia y componentes |
| Persistencia | repositorio PDO / Capsule standalone | Eloquent integrado |
| Esquema de BD | script tienda_orm.sql único | migraciones versionadas + seeders |
| Validación | bolsa de errores con filter_var | Validator + Form Requests |
| XSS | helper e() propio | Blade escapa automáticamente |
| Sesiones y flash | $_SESSION envuelto a mano | Session + redirects with() |
| Tareas pesadas | scripts CLI con señales pcntl | colas + scheduler integrados |
Un mismo endpoint, dos mundos
Así listábamos pedidos en el manual MVC: una ruta entraba al front controller, un switch decidía quién atiende:
<?php
// Nuestro front controller (MVC, resumen)
$ruta = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
switch ($ruta) {
case '/pedidos': (new ControladorPedidos)->listar(); break;
case '/pedido/crear': (new ControladorPedidos)->crear(); break;
default: http_response_code(404);
require __DIR__.'/../vistas/404.php';
}Aquí la misma idea, pero declarativa — el archivo COMPLETO de rutas web:
<?php
// routes/web.php
use App\Http\Controllers\PedidoControlador;
use Illuminate\Support\Facades\Route;
Route::get('/', [InicioControlador::class, 'index']);
Route::get('/pedidos', [PedidoControlador::class, 'listar']);
Route::get('/pedido/crear', [PedidoControlador::class, 'crear']);Nada de parsear REQUEST_URI: alguien ya lo hizo, lo probó en todos los
servidores imaginables y le agregó parámetros dinámicos, caché de rutas y nombres para
generar URLs desde vistas. Cada pieza que escribimos existe aquí — con años de
mantenimiento encima.
Convención sobre configuración
La filosofía central: si sigues las convenciones, NO configuras nada. Un modelo vacío ya sabe su tabla, su clave primaria y su conexión:
<?php
// app/Models/Cliente.php — cero configuración
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Cliente extends Model {}
// más adelante probaremos en tinker:
// echo (new Cliente)->getTable(); // deduce "clientes"Clase Cliente → tabla clientes; clave primaria
id; columnas created_at/updated_at gestionadas solas.
¿Te suena? Son exactamente las convenciones que el esquema tienda_orm ya cumplía —
las heredamos gratis porque tomamos esas decisiones hace dos manuales.
Lo que NO cambia
El framework acelera el trabajo; no anula el criterio. Las reglas de la casa viajan completas:
- Jamás consultar dentro de un loop: with() sigue siendo obligatorio (cap. 18).
- Escritura multi-tabla SIEMPRE en transacción (alta de pedido con detalles).
- Dinero en DECIMAL, jamás float — los casts respetan lo que el esquema decide.
- orderBy antes de limit: paginate() también necesita orden determinista.
- Presupuesto de consultas por pantalla: el query log sigue existiendo aquí.
Puntos clave
- Cada pieza de nuestros manuales MVC/Eloquent tiene equivalente directo aquí.
- Rutas declarativas vs switch: misma operación, mantenimiento distinto.
- Convención sobre configuración: modelo vacío = tabla clientes deducida.
- Las reglas de la casa (N+1, transacciones, DECIMAL) siguen mandando.
2 · Requisitos e instalación
Básico ~16 minYa dominamos Composer y sabemos instalar PHP por la vía seria (el manual lenguaje lo hizo con el PPA de Ondřej en Linux y el paquete oficial en Windows). Instalar Laravel es entonces un paso corto — pero hay tres cosas que casi nadie mira hasta que explotan: las extensiones exactas, los permisos de las dos carpetas escribibles y qué le pasa a tu proyecto si las ignoras.
- Verificar PHP 8.3+ y la lista completa de extensiones requeridas.
- Crear el proyecto por las dos vías: installer o create-project.
- Elegir entorno local con criterio (Herd / Laragon / XAMPP / PPA).
- Dar permisos correctos a storage/ y bootstrap/cache/.
- Certificar la salud del proyecto con artisan about.
Los requisitos EXACTOS
Laravel 13 exige PHP >= 8.3 (Laravel 12 pedía 8.2). Y no basta el intérprete: el framework necesita un puñado de extensiones. La lista oficial:
ctype, curl, dom, fileinfo, filter, hash,
mbstring, openssl, pdo, session, tokenizer, xmlMás la extensión del driver de TU base de datos — para nosotros
pdo_mysql (MariaDB). Casi todas vienen habilitadas de fábrica; las que
suelen faltar son mbstring y el driver PDO. Verifiquémoslo todo de una:
php -m | grep -icE '^(ctype|curl|dom|fileinfo|filter|hash|mbstring|openssl|pdo|pdo_mysql|session|tokenizer|xml)$'
Trece coincidencias = trece extensiones presentes. Si te falta una, ya sabes el
remedio de php_01: sudo apt install php8.5-mbstring (o el paquete
equivalente) + reiniciar el servicio. Nota fina: pcre y
json no aparecen porque viven dentro del núcleo desde PHP 8 — ni se
listan, pero están.
Dos vías hacia el mismo proyecto
composer global require laravel/installer
laravel new pedidos
# vía B: equivalente 100% Composer (ya la conoces)
composer create-project laravel/laravel pedidos
Ambas hacen lo mismo: descargar el esqueleto, resolver dependencias de vendor,
crear el .env inicial y ejecutar key:generate — esa clave de
32 bytes que cifra sesiones y cookies. El installer pregunta por base de datos y
starter kits; para este manual responde MariaDB cuando toque (capítulo 14) y ninguno
de los kits de frontend: Blade puro es nuestra ruta.
php artisan serve
Ahí está corriendo — mismo truco cli-server que descubrimos con
php -S, ahora envuelto en artisan. Abre la URL y verás la pantalla de
bienvenida.
Elegir entorno local
| Camino | Plataforma | Qué aporta | Ideal si… |
|---|---|---|---|
| Herd | macOS / Windows | oficial; PHP + nginx sin config | quieres cero fricción y pagas extras |
| Laragon | Windows | todo-en-uno, multi-PHP, portable | vives en Windows con varios proyectos |
| XAMPP | cruzado | Apache + MariaDB + PHP clásicos | solo necesitas replicar hosting viejo |
| PPA Ondřej + Composer | Linux | control total, servicios reales | nuestra vía desde php_01 (la seguiremos) |
Nosotros seguimos en Linux nativo con MariaDB real: nada de emular después lo que hoy puedes hacer bien. Los comandos de este manual asumen esa vía.
Los DOS directorios escribibles
Laravel es inusualmente ordenado: TODO el proyecto puede ser de solo lectura salvo dos carpetas donde guarda logs, sesiones, vistas compiladas, caché y subidas:
storage/ → logs, sesiones, caché de vistas, archivos del usuario
bootstrap/cache/ → config y rutas cacheadas + estado del modo mantenimientoEn desarrollo con tu usuario todo funciona sin tocar nada. El problema aparece
cuando Apache/nginx (usuario www-data) debe escribir ahí — típico al
probar con servidor real o desplegar. El arreglo canónico:
sudo chmod -R ug+rwx storage bootstrap/cache
# alternativa ACL: tú sigues siendo dueño y www-data obtiene acceso:
sudo setfacl -R -m u:www-data:rwx -m d:u:www-data:rwx storage bootstrap/cache
Síntoma clásico de permisos mal puestos: «failed to open stream: Permission
denied» apuntando a storage/framework/views — la vista no pudo
compilarse. Con la ACL además el flag d: hereda el permiso a archivos
nuevos. En Windows/Laragon/Herd esto se gestiona solo.
Chequeo de salud: artisan about
Nuestro primer comando de diagnóstico serio — resume entorno, cachés y drivers en una pantalla:
Dos lecturas importantes. Primera: Maintenance mode OFF — ese interruptor que mencionamos existe desde el minuto cero y lo usaremos en producción. Segunda: el driver de base dice sqlite: Laravel trae SQLite por defecto para que el proyecto arranque sin configurar nada. Nosotros lo cambiaremos a MariaDB en el capítulo 14 — decisión consciente, no accidente.
Puntos clave
- PHP >= 8.3 + doce extensiones + pdo_mysql: verificable con php -m.
- installer o create-project: ambas generan .env y APP_KEY solas.
- storage/ y bootstrap/cache/: únicos escribibles; ACL para convivir con www-data.
- php artisan about: diagnóstico completo en un comando.
- SQLite viene por defecto; cambiarlo será decisión explícita en cap. 14.
3 · Anatomía del proyecto
Básico ~13 minAbrir la carpeta de un proyecto Laravel nuevo puede abrumar: veinte carpetas donde nuestro mini-framework tenía cinco. La buena noticia: cada una tiene UN trabajo, y casi todas son hogar de cosas que ya construimos en versión casera. Recorrido completo con mapa en mano.
- Leer el árbol de directorios sabiendo qué vive (y qué NO) en cada uno.
- Mapear cada carpeta con su equivalente del manual MVC.
- Entender bootstrap/app.php como único cuartel general.
- Recorrer el viaje completo de una petición por esas carpetas.
El mapa de carpetas
pedidos/
├── app/ TU código (PSR-4: namespace App\)
│ ├── Http/
│ │ └── Controllers/ controladores (eco de nuestros controladores)
│ ├── Models/ Cliente, Producto... (eco de modelos.php)
│ └── Providers/ servicios que se registran al arrancar
├── bootstrap/
│ ├── app.php ÚNICO configurador desde Laravel 11
│ └── cache/ config/rutas cacheadas + modo mantenimiento
├── config/ archivos PHP de configuración (cap. 5)
├── database/
│ ├── factories/ datos de prueba declarativos (cap. 19)
│ ├── migrations/ el esquema VERSIONADO (caps. 15-16)
│ └── seeders/ semillas deterministas (cap. 19)
├── public/ lo ÚNICO expuesto al navegador
│ └── index.php el front controller YA no es tuyo
├── resources/views/ plantillas Blade (eco de nuestras vistas)
├── routes/
│ ├── web.php rutas de navegador (cap. 7)
│ └── console.php closures CLI + tareas programadas
├── storage/ logs, sesiones, caché, subidas
├── tests/ pruebas Pest (Parte VIII)
├── vendor/ Composer — jamás editar
└── .env credenciales locales (fuera de git)La regla de oro del despliegue ya la intuyes: el DocumentRoot apunta a public/, igual que hicimos con .htaccess apuntando todo al front controller. Todo lo demás queda inaccesible desde fuera — seguridad gratis.
Dónde vive cada cosa de nuestro MVC
| Lo artesanal | Su hogar en Laravel | Se explica a fondo en |
|---|---|---|
| front controller propio | public/index.php + Router | cap. 4 (hoy) |
| rutas en switch/regex | routes/web.php declarativo | cap. 7 |
| contenedor de clases propio | Service Container | cap. 8 |
| vistas con extract/include | resources/views/*.blade.php | caps. 9–10 |
| repositorios PDO | app/Models + Eloquent | caps. 14–19 |
| script tienda_orm.sql | migrations + factories + seeders | caps. 15–16 y 19 |
| helper e() y validador | Blade escapa solo + Validator | caps. 6, 12 y 33 |
bootstrap/app.php: el cuartel general
Si vienes de tutoriales viejos buscarás app/Http/Kernel.php y un
Console Kernel: ya no existen. Desde Laravel 11 TODO se declara fluido en un único
archivo que probablemente sea el más corto del proyecto:
<?php
// bootstrap/app.php — completo, así de nuevo
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up', // latido HTTP para balanceadores
)
->withMiddleware(function (Middleware $middleware) {
// aliases y grupos: cap. 11
})
->withExceptions(function (Exceptions $exceptions) {
// reporte y render de errores: cap. 41
})->create();Tres llamadas cubren el arranque entero: dónde están las rutas, qué middleware aplica y cómo se reportan excepciones. Cuando agreguemos aliases propios o Sanctum, será AQUÍ donde se registran — un solo lugar que memorizar.
El viaje de una petición
petición HTTP
│
▼
public/index.php única entrada expuesta (DocumentRoot aquí)
│ carga autoloader Composer + bootstrap/app.php
▼
Application::configure() arma el contenedor y lee .env
│
▼
Request → pipeline de middleware globales → Router (routes/web.php)
│
▼
grupo web: sesiones, cookies cifradas, verificación CSRF
│
▼
tu closure / controlador → devuelve Response
│
▼
la respuesta vuelve por el MISMO túnel de middleware → navegadorCompáralo con el diagrama del capítulo 1 del manual MVC: reconocerás cada tramo. La diferencia es quién mantiene los tramos intermedios — ellos ahora. Tu código vive en los extremos: rutas, controladores y vistas.
composer install (jamás editar); .env nunca entra a git
(ya lo decidimos en el capítulo de Composer de php_01); y cuando algo se comporte
extraño tras cambiar configuración, el culpable suele vivir en
bootstrap/cache/ — se limpia solo, lo vemos en el cap. 42.Puntos clave
- Cada carpeta = un trabajo; app/ es tuyo, el resto es infraestructura.
- DocumentRoot siempre a public/: nada ajeno queda expuesto.
- bootstrap/app.php reemplaza los Kernel: rutas, middleware y errores en un archivo.
- health '/up': latido listo para balanceadores sin escribir código.
- El viaje petición→respuesta replica el de nuestro front controller, mantenido por el framework.
4 · Artisan y el ciclo de vida
Intermedio ~14 minYa viste artisan en acción (serve, about). Es hora de
tratarlo como lo que es: la consola unificada del framework — lo que para nosotros
eran scripts CLI sueltos, documentación dispersa y memoria muscular. Aquí aprenderás
su gramática una vez y te servirá para TODOS sus comandos, presentes y futuros.
- Dominar la gramática común: comando, argumentos, opciones y flags universales.
- Descubrir comandos sin Google: list y namespaces.
- Usar tinker como REPL con el framework arrancado completo.
- Recorrer el ciclo de vida de la petición, tramo por tramo, con nombres reales.
La gramática de un comando
php artisan <comando> [argumentos] [--opciones]
php artisan migrate --force --seed
php artisan route:list --path=pedidos
php artisan make:model Cliente -mfsc ← las letras también son opcionesArgumentos = posicionales y obligatorios según el comando; opciones = modificadores con
doble guion, algunas con valor (--path=x) y otras booleanas
(--force). Y hay un puñado de flags UNIVERSALES que funcionan en todos:
| Flag | Efecto | Cuándo vive bien |
|---|---|---|
| -h, --help | manual del comando: opciones propias incluidas | siempre, antes que buscar en internet |
| -n, --no-interaction | responde "no" a todo aviso interactivo | despliegues y cron (nadie mirará la pantalla) |
| -q | silencio total | tareas programadas que solo interesan si fallan |
| -v / -vv / -vvv | verbosidad normal / detallada / debug completo | -vvv imprime stack trace íntegro ante errores |
| --ansi / --no-ansi | colores on/off | logs donde el color ensucia |
php artisan loquesea --help. La ayuda se genera sola de la firma del
comando y SIEMPRE está al día con tu versión — cosa que un tutorial web no promete.Descubrir el arsenal sin salir de la terminal
php artisan list make
Cada namespace es una familia: make: genera código,
migrate: maneja el esquema, cache:/config:/view: gestionan
cachés, queue: las colas. Los iremos conociendo EN SU CAPÍTULO — pero ya
sabes encontrarlos todos.
tinker: el framework entero en una consola
Nuestro boot.php de Eloquent existía para poder probar piezas sin servidor. Tinker es eso, oficial: arranca TODO el framework y te da un REPL:
Fíjate: now() devuelve Carbon — el mismo que descubrimos en Eloquent
cuando leímos fechas de modelos, aquí disponible desde el arranque. Cuando tengamos
modelos (cap. 18), tinker será nuestro banco de pruebas permanente.
El ciclo de vida, tramo por tramo
- 1 · Entrada: el servidor manda TODA URL a public/index.php — el rewrite de .htaccess/nginx que configuramos a mano en MVC, aquí preinstalado.
- 2 · Arranque: index.php carga Composer y ejecuta bootstrap/app.php: contenedor armado, .env leído.
- 3 · Request: la petición HTTP cruda se convierte en un objeto Request — con headers, input y archivos ya parseados.
- 4 · Middleware globales: la Request atraviesa una tubería; cada pieza puede inspeccionarla, modificarla o cortar el paso.
- 5 · Routing: el Router compara contra routes/web.php y decide quién atiende; los parámetros de ruta se extraen aquí.
- 6 · Grupo web: sesiones, cookies cifradas y verificación CSRF — el trio de seguridad que implementamos a mano, aplicándose solo.
- 7 · Acción: tu closure o controlador recibe la Request y devuelve CUALQUIER cosa: texto, array (→ JSON), vista o Redirect.
- 8 · Salida: todo se normaliza a Response y regresa por el mismo túnel de middleware hacia el navegador.
De esos ocho tramos, en el manual MVC escribimos el 1, el 3, el 5 y el 7 con nuestras propias manos. El framework no cambió el mapa — cambió QUIÉN mantiene cada tramo. Y esa es exactamente la mentalidad con la que recorreremos el resto del manual: reconocer, confiar y luego extender.
Puntos clave
- Una gramática vale para cientos de comandos: argumentos, opciones, -h, -n, -vvv.
- list (y list NAMESPACE) reemplazan al buscador: el catálogo vive en tu terminal.
- tinker = boot.php oficial: framework completo en REPL, Carbon incluido.
- Ocho tramos entre petición y respuesta; cuatro ya los escribimos a mano.
5 · Configuración y variables de entorno
Básico ~13 minEn nuestros proyectos artesanales la configuración vivía en un archivo propio: credenciales de BD, constantes de dominio, el modo debug. Laravel separa esa idea en DOS capas con roles distintos — y la frontera entre ambas es una de las trampas clásicas del framework. Aquí queda clavada.
- Distinguir qué vive en .env y qué vive en config/.
- Leer cualquier valor con config() y notación de punto.
- Absorber la regla de oro: env() jamás fuera de los archivos de configuración.
- Crear tu propio archivo de configuración para el dominio Pedidos.
El reparto: valores vs estructura
.env guarda VALORES que cambian por máquina (credenciales, modo debug,
URL). Los archivos de config/ definen ESTRUCTURA y valores por defecto,
leyendo el .env con env():
<?php
// config/app.php (fragmento) — estructura + default
return [
'name' => env('APP_NAME', 'Laravel'), // si .env no dice nada: "Laravel"
'env' => env('APP_ENV', 'production'),
'debug' => (bool) env('APP_DEBUG', false),
// ...
];# .env del proyecto pedidos (generado en el cap. 2)
APP_NAME=Pedidos
APP_ENV=local
APP_KEY=base64:... ← generada sola por key:generate
APP_DEBUG=true
DB_CONNECTION=sqlite ← default; cap. 14 la apunta a MariaDB
SESSION_DRIVER=database
QUEUE_CONNECTION=databaseSegundo argumento de env() = valor si la variable no existe. El .env
NUNCA entra a git (ya lo decidimos); lo que viaja al repositorio es
.env.example, la plantilla con las claves pero sin secretos.
Leer con config() y puntos
La ruta de lectura es nombre-de-archivo.clave — y anidada cuanta
profundidad haga falta:
Ningún archivo se registra a mano: Laravel carga TODO el directorio
config/ al arrancar, y cada nombre de archivo se vuelve la primera clave.
Eso ya te funcionó sin que lo supieras: about, serve
y las sesiones leen de ahí.
La regla de oro: env() solo dentro de config/
config:cache, que compila toda la
configuración a un archivo único en bootstrap/cache/ y descarta las variables
de entorno del proceso: tus llamadas env() devolverán null — en silencio, solo
en el servidor, y probablemente un viernes.La división canónica:
env()→ SOLO dentro de archivos de config/: traduce máquina → aplicación.config()→ en TODO el resto del código: lee del arreglo ya cargado.- Cambio de valor por servidor = tocar SOLO .env, jamás el código.
Es la misma disciplina de nuestro .env artesanal con credenciales de
PDO — ahora con una regla clara de quién puede leer qué.
Configuración propia del dominio
Creemos el archivo que usaremos todo el manual — constantes de negocio de Pedidos, con valores sobre-escribibles por entorno:
<?php
// config/pedidos.php — estructura propia, valores por .env
return [
'moneda' => env('PEDIDOS_MONEDA', 'PEN'),
'igv' => (float) env('PEDIDOS_IGV', 0.18),
'estados' => ['REGISTRADO', 'PAGADO', 'ANULADO'], // eco ENUM del esquema
];Sin registrar nada, ya disponible en todas partes:
Los estados del ENUM de tienda_orm ahora son configuración consultable — el formulario del cap. 12 los validará contra esta lista en vez de repetirlos.
config:clear,
luego config:cache). Es el mismo archivo bootstrap/cache/ que ya
señalamos como escribible — lo operamos a fondo en el cap. 42.Puntos clave
- .env = valores por máquina; config/ = estructura con defaults vía env().
- config('archivo.clave') en todo el código; env() SOLO dentro de config/.
- config:cache descarta variables de entorno: env() fuera de config/ = null silencioso.
- Tu config/pedidos.php carga solo: moneda, IGV y estados del dominio.
6 · Helpers: los del framework y los tuyos
Intermedio ~15 minEn php_01 aprendimos que las funciones globales son comodidad con costo; en
el MVC creamos nuestro helpers.php con e() y compañía. Laravel lleva
esa idea al extremo: decenas de funciones globales disponibles SIEMPRE — y un
mecanismo oficial para sumar las nuestras sin tocar el framework.
- Conocer los helpers integrados que usaremos capítulo sí, capítulo también.
- Crear app/Support/helpers.php y registrarla en composer.json.
- Comparar la alternativa moderna: clases inyectables y macros de Collection.
- Saber cuándo usar cada forma (tabla de decisión).
Los integrados del día a día
| Helper | Devuelve | Ejemplo |
|---|---|---|
| route('pedidos.ver', ['id' => 7]) | URL generada por nombre | "/pedido/7" |
| view('pedidos.lista') | vista lista para renderizar | cap. 9 |
| asset('css/app.css') | URL pública desde / | "http://.../css/app.css" |
| old('correo') | valor previo del formulario | cap. 12 |
| csrf_token() | token anti-falsificación | cap. 12 |
| config('pedidos.igv') | valor de configuración | cap. 5 |
| logger('mensaje') | escribe en storage/logs | cap. 41 |
| now() | Carbon actual | visto en tinker (cap. 4) |
| dump() / dd() | inspeccionar y seguir / inspeccionar y morir | eco var_dump disciplinado |
No hay que importarlos: viven en el espacio global desde el arranque. Los de
formularios (old, csrf_token) y vistas cobrarán sentido en la
Parte II — la tabla es tu mapa de llegada.
Nuestros propios helpers
Laravel NO tiene carpeta oficial de helpers — decisión deliberada: los tuyos van
donde tu arquitectura diga. La ubicación de facto es app/Support/:
<?php
// app/Support/helpers.php
if (! function_exists('moneda')) {
/** Monto en soles con dos decimales: eco del formato del manual Eloquent. */
function moneda(float|string $monto): string
{
return 'S/ '.number_format((float) $monto, 2);
}
}
if (! function_exists('etiqueta_estado')) {
/** Clase de badge Bootstrap según el estado del pedido. */
function etiqueta_estado(string $estado): string
{
return match ($estado) {
'PAGADO' => 'success',
'REGISTRADO' => 'warning',
'ANULADO' => 'danger',
default => 'secondary',
};
}
}Esa guarda if (! function_exists(...)) no es paranoia: es LA convención
— el propio núcleo define sus helpers así. Si un paquete tercero definiera
moneda(), PHP usará la PRIMERA declarada y nadie explotará al instalar.
El registro: autoload files + dump-autoload
Composer ya nos enseñó dos formas de autocarga: PSR-4 (clase → archivo, perezosa) y
files (archivos que se cargan SIEMPRE). Un helper global necesita la
segunda:
{
"autoload": {
"files": [
"app/Support/helpers.php"
],
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/"
}
}
}Diferencia clave: PSR-4 solo carga cuando ALGUIEN usa la clase;
files carga SIEMPRE en cada request — por eso los helpers son baratos
pero deben ser pocos y pequeños. Tras el dump-autoload:
La alternativa moderna: clases y macros
Los helpers globales son ideales para vistas y detalles de presentación, pero la lógica de negocio prefiere objetos — testables, inyectables, sin contaminar el global. Las tres vías oficiales:
<?php
// Opción A: clase final con métodos estáticos (namespace = IDE feliz)
namespace App\Support;
final class Formateador
{
public static function soles(float|string $monto): string
{
return 'S/ '.number_format((float) $monto, 2);
}
}
// Opción B: macro sobre Collection (operación repetible sobre listas)
use Illuminate\Support\Collection;
Collection::macro('enSoles', function () {
return $this->map(fn ($m) => moneda($m));
});
collect([62.85, 258.20])->enSoles(); // ["S/ 62.85", "S/ 258.20"]| Vía | Úsala cuando… | Costo |
|---|---|---|
| helper global | vistas y plantillas: brevedad manda | cargado siempre; global real |
| clase inyectable | reglas de negocio, tests, servicios | import + instancia (o estáticos) |
| macro de Collection | transformación reutilizable de listas | registro único (provider) |
Y el eco esperado: nuestro e() artesanal casi desaparece — Blade escapa
TODA salida automáticamente (lo verificamos en acción en el cap. 9). El framework toma
la regla de seguridad más importante del manual anterior y la vuelve imposible de
olvidar.
Puntos clave
- Los integrados (route, view, old, now…) cubren el 90% del día a día.
- app/Support/helpers.php + autoload.files + dump-autoload = tus helpers.
- Guarda if (!function_exists): convención anti-colisión del propio núcleo.
- Negocio → clase inyectable; vista → helper; lista repetida → macro.
- e() ya casi no se necesita: Blade escapa solo (se comprueba en el cap. 9).
7 · Rutas: parámetros, nombres y grupos
Básico ~16 minNuestro router artesanal hacía preg_match('#^/pedido/(\d+)$#', $ruta)
para capturar un id. Aquí la misma intención es una línea declarativa — con
validación incluida, nombres estables y agrupación por módulos. Las rutas son el
CONTRATO público de tu aplicación: merecen capítulo propio.
- Definir rutas por verbo HTTP y responder strings, arrays (JSON) y vistas.
- Capturar parámetros con reglas whereNumber/whereAlpha — 404 automático.
- Bautizar rutas con name() y generar URLs con route().
- Agrupar por prefijo y nombre: el módulo admin de Pedidos.
Verbos y respuestas
Cada método estático de Route corresponde a un verbo HTTP. Lo que devuelve la función define la respuesta: string → HTML/texto; array → JSON automático; vista o redirect → sus objetos:
<?php
// routes/web.php
use Illuminate\Support\Facades\Route;
Route::view('/', 'welcome'); // pantalla del cap. 2
Route::get('/saludo/{nombre}', function (string $nombre) {
return 'Hola, '.$nombre;
})->whereAlpha('nombre');
Route::get('/pedido/{id}', function (int $id) {
// provisionales: modelos llegan en la Parte III
return ['id' => $id, 'estado' => 'REGISTRADO', 'total' => 321.05];
})->whereNumber('id')->name('pedidos.ver');curl -s http://127.0.0.1:8000/pedido/7
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8000/pedido/abc
Tres hallazgos en una sola prueba. El array se serializó solo (nuestro
responderJson() del MVC, gratis). El type-hint int $id no es
decorativo: donde el regex casero fallaba con un warning, aquí la restricción corta con
404 limpio. Y el parámetro llega ya convertido — sin validar nada.
Reglas de parámetros: la familia where
| Método | Acepta | Eco de nuestro regex |
|---|---|---|
| whereNumber('id') | solo dígitos | (\d+) |
| whereAlpha('nombre') | solo letras | ([a-zA-Z]+) |
| whereAlphaNumeric('slug') | letras y números | ([a-zA-Z0-9]+) |
| where('slug', '[a-z0-9-]+') | tú mandas el regex | el caso general |
| {id?} con default | parámetro opcional | (\d+)? + if |
Nombres: URLs que pueden cambiar sin romper nada
->name('pedidos.ver') bautiza la ruta; desde entonces TODO el código
debe referirla por nombre, jamás por texto:
Si mañana las URLs pasan a /pedidos/{id}, cambian UNA línea de
routes/web.php y cada vista, redirect y test sigue funcionando. Con nuestro switch,
eso era buscar-y-reemplazar rezando. Bonus para menús:
request()->routeIs('pedidos.*') marca activo todo lo que empiece así.
Grupos: el módulo admin
<?php
// Prefijo URL + prefijo de nombre + middleware del grupo
Route::prefix('admin')->name('admin.')->group(function () {
Route::get('/panel', function () {
return 'Panel admin';
})->name('panel');
Route::get('/reportes', function () {
return 'Reportes';
})->name('reportes');
});URLs /admin/*, nombres admin.panel,
admin.reportes — y cuando pidamos sesión (cap. 31), UN middleware en el
grupo protege las dos rutas. Estructura = seguridad + orden.
php artisan route:list lista
todo el mapa (con --path=x filtra). En producción estas rutas se cachean para
respuesta instantánea — ahí los controladores son la forma canónica, tema del
capítulo que viene.Puntos clave
- Método HTTP → método Route; array devuelto = JSON automático.
- whereNumber/whereAlpha: restricción fallida = 404, sin warnings.
- Referencia SIEMPRE por name() + route(): URLs cambiantes, código estable.
- prefix+name+middleware en group: módulos con orden y seguridad conjunta.
- route:list es el espejo oficial del contrato de tu app.
8 · Controladores: el músculo HTTP
Intermedio ~14 minLas closures del capítulo 7 sirven para aprender; en una app real, la lógica HTTP vive en clases. Laravel genera los esqueletos por ti — y su convención de resource controller calca EXACTAMENTE los siete métodos que inventamos en el CRUD del manual MVC. Abre la Parte II con la traducción más satisfactoria del manual.
- Generar controladores con make:controller y sus variantes (--resource, --api, --invokable).
- Mapear nuestro CRUD artesanal a los 7 métodos resource.
- Conectarlos con Route::resource: siete rutas nombradas en una línea.
- Inyectar dependencias por type-hint (Request y las tuyas).
make:controller y variantes
php artisan make:controller SaludoControlador
# una sola acción (__invoke): rutas "hacer una cosa"
php artisan make:controller FotoControlador --invokable
# CRUD completo + type-hints de modelo:
php artisan make:controller ProductoControlador --resource --model=Producto
# API: 5 métodos (sin create/edit, que son de formularios web)
php artisan make:controller Api/PedidoControlador --api
La barra en Api/PedidoControlador crea subcarpeta y namespace
App\Http\Controllers\Api — organiza tu API desde el primer día (cap. 26).
Cada variante existe porque cada contexto lo pide; --resource es el caballo de batalla
web.
Los siete métodos: nuestro CRUD, traducido
| Nuestro método MVC | Resource Laravel | Verbo + URI |
|---|---|---|
| listar() | index() | GET /productos |
| crear() — formulario | create() | GET /productos/create |
| guardar() — recibe POST | store() | POST /productos |
| ver($id) | show($id) | GET /productos/{producto} |
| editar($id) — formulario | edit($id) | GET /productos/{producto}/edit |
| actualizar($id) | update($id) | PUT/PATCH /productos/{producto} |
| borrar($id) | destroy($id) | DELETE /productos/{producto} |
No es coincidencia ni azar pedagógico: la industria convergió en este mapa RESTful y nosotros lo escribimos a mano dos manuales atrás. El esqueleto generado:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class ProductoControlador extends Controller
{
public function index() {}
public function create() {}
public function store(Request $request) {}
public function show(string $id) {}
public function edit(string $id) {}
public function update(Request $request, string $id) {}
public function destroy(string $id) {}
}Una línea = siete rutas nombradas
<?php
// routes/web.php
use App\Http\Controllers\ProductoControlador;
Route::resource('productos', ProductoControlador::class);Siete rutas con nombres predecibles (productos.index,
productos.store…) listos para route(). El parámetro se llama
{producto} — singular del recurso — y cuando exista el modelo real será
route model binding (cap. 20): llega el OBJETO ya buscado o 404.
Inyección de dependencias
El contenedor resuelve los type-hints de tus métodos — Request primero, luego los parámetros de ruta por nombre:
<?php
public function store(Request $request)
{
// todo el input validado-crudo disponible:
$datos = $request->all();
// validación formal y limpia: capítulo 12
}Es nuestro contenedor casero del MVC con auto-resolución real: pides lo que necesitas en la firma y aparece. Cuando tengamos servicios propios (un calculador de totales, por ejemplo), un type-hint basta — sin registrar fábrica alguna.
#[Middleware('auth')]
encima del controlador. Lo usaremos de verdad cuando haya sesión (cap. 31); por ahora
anota que existe y vive junto al código que protege.Puntos clave
- make:controller --resource genera los 7 métodos del CRUD estándar.
- index/create/store/show/edit/update/destroy ≈ listar/crear/guardar/ver/editar/actualizar/borrar.
- Route::resource: una línea → 7 rutas con nombre predecible.
- Type-hint en la firma = inyección automática (Request y tus servicios).
- --api recorta create/edit: la Parte V nace así.
9 · Blade I: plantillas que escapan solas
Básico ~15 minNuestro motor artesanal hacía str_replace() sobre marcadores y
extract() para las variables. Blade va mucho más lejos: COMPILA cada
plantilla a PHP puro y la cachea en storage/framework/views — la carpeta que
hicimos escribible en el capítulo 2 ya tiene su inquilino oficial.
- Entregar datos a una vista desde la ruta con view() + arreglo.
- Imprimir SIEMPRE escapado con {{ }} — XSS imposible por descuido.
- Controlar el flujo con @if/@foreach/@forelse y la variable $loop.
- Saber cuándo (y cuándo NO) usar {!! !!} y @php.
De ruta a vista, con datos
Segundo argumento de view() = variables disponibles dentro de la
plantilla. Archivo pedidos/lista = resources/views/pedidos/lista.blade.php
(punto separa carpetas):
<?php
// routes/web.php
Route::get('/demo/lista', function () {
$pedidos = [
['id' => 1, 'cliente' => 'Ana Quispe', 'estado' => 'PAGADO', 'total' => 258.20],
['id' => 2, 'cliente' => 'Luis Ramos', 'estado' => 'REGISTRADO', 'total' => 62.85],
['id' => 3, 'cliente' => 'Carmen Rojas', 'estado' => 'ANULADO', 'total' => 120.00],
];
return view('pedidos.lista', ['pedidos' => $pedidos]);
});<!-- resources/views/pedidos/lista.blade.php -->
<h1>Pedidos</h1>
@foreach ($pedidos as $p)
<p>
#{{ $p['id'] }} — {{ $p['cliente'] }}
<span class="badge text-bg-{{ etiqueta_estado($p['estado']) }}">
{{ $p['estado'] }}
</span>
{{ moneda($p['total']) }}
</p>
@endforeachFíjate: DENTRO de {{ }} hay PHP completo — ahí reutilizamos los helpers del capítulo 6 sin importarlos. El resultado:
La llave doble que te salva: {{ }} escapa SIEMPRE
<?php
// un usuario malicioso se registra con este "comentario":
$comentario = '<script>alert("XSS")</script>Hola';| En la plantilla | El navegador recibe | Consecuencia |
|---|---|---|
| {{ $comentario }} | <script>...</script> | se MUESTRA como texto: inofensivo |
| {!! $comentario !!} | <script>...</script> | se EJECUTA: roba sesiones |
{{ }} pasa la salida por e() — nuestro helper del manual php_01, ahora
integrado e inevitable. {!! !!} existe para HTML CONFiable que TÚ generaste (Markdown
renderizado, HTML almacenado tras sanitizar); jamás para input directo de usuarios.
Regla de casa intacta: la seguridad no se negocia.
Directivas de control
Toda directiva abre con @ y cierra con @end…; compilan a los mismos if/foreach de siempre:
@if ($p['estado'] === 'ANULADO')
<del>{{ moneda($p['total']) }}</del>
@elseif ($p['estado'] === 'PAGADO')
<b>{{ moneda($p['total']) }}</b>
@else
{{ moneda($p['total']) }}
@endif
@isset($usuario) <!-- variable definida Y no nula -->
@empty($pedidos) <!-- count() == 0 -->
@unless ($activo) <!-- el "if not" legible -->Bucles con superpoderes: $loop
@forelse ($pedidos as $p)
<tr class="{{ $loop->even ? 'par' : 'impar' }}">
<td>{{ $loop->iteration }} de {{ $loop->count }}</td>
<td>{{ $p['cliente'] }}
@if ($loop->first) «primero »@endif
</td>
</tr>
@empty
<p>Aún no hay pedidos registrados.</p>
@endforelse
@foreach ($pendientes as $x)
@continue ($x['anulado']) <!-- saltar este -->
@break ($loop->index >= 10) <!-- cortar todo -->
@endforeach| $loop->… | Significado |
|---|---|
| index / iteration | índice 0-based / número 1-based |
| first / last | true en el primer / último elemento |
| even / odd | para zebra en tablas |
| count | total de elementos |
| parent | $loop del foreach EXTERIOR (bucles anidados) |
@forelse cubre LA pregunta clásica «¿y si viene vacío?» — en el MVC era
if (empty($filas)) … else … duplicando la estructura.
Comentarios y @php: último recurso
{{-- nota --}} desaparece de la respuesta final (los comentarios
HTML <!-- --> viajan al navegador). Y @php … @endphp existe, pero
trátalo como deuda: si necesitas lógica real, pertenece al controlador o a un helper,
no a la vista.
Puntos clave
- .blade.php compila a PHP puro cacheado en storage/framework/views.
- {{ }} = eco escapado SIEMPRE (e() integrado); {!! !!} solo HTML confiable propio.
- @forelse/@empty responde listas vacías sin duplicar estructuras.
- $loop da index/first/last/even/odd/count/parent en cualquier bucle.
- Dentro de {{ }} hay PHP: tus helpers del cap. 6 funcionan directo.
10 · Blade II: layouts y componentes
Intermedio ~17 minEn el MVC teníamos un layout con secciones y render() que
rellenaba huecos. Blade ofrece DOS generaciones para lo mismo: la herencia
clásica (@extends — calcada de la nuestra) y los COMPONENTES, la vía moderna y
recomendada. Verás las dos: entender la primera hace obvia la segunda.
- Armar un layout con @yield/@section/@extends y saber cuándo basta.
- Crear componentes de clase con make:component: slots y propiedades.
- Usar componentes anónimos con @props para piezas pequeñas.
- Reutilizar fragmentos con @include y cargar JS/CSS por página con @push.
Generación clásica: herencia con @extends
<!-- resources/views/layouts/app.blade.php -->
<!DOCTYPE html>
<html lang="es">
<head>
<title>@yield('titulo', 'Pedidos')</title>
@stack('css')
</head>
<body>
<header>Pedidos · nav aquí</header>
<main>
@yield('contenido') <!-- el hueco que rellena el hijo -->
</main>
@stack('scripts')
</body>
</html><!-- resources/views/pedidos/lista.blade.php (hija) -->
@extends('layouts.app')
@section('titulo', 'Lista de pedidos')
@section('contenido')
<h1>Pedidos</h1>
{{-- ...lo del capítulo 9... --}}
@endsection
@push('scripts')
<script src="{{ asset('js/solo-lista.js') }}"></script>
@endpush@yield define hueco; @section del hijo lo rellena (con @parent hereda y AÑADE).
Es exactamente nuestra función layout() + arreglo de secciones del MVC,
compilada a PHP puro. Sigue soportado y es perfecto para apps sencillas.
La vía moderna: componentes con clase
# → app/View/Components/Alerta.php
# → resources/views/components/alerta.blade.php
<?php
namespace App\View\Components;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class Alerta extends Component
{
public function __construct(
public string $tipo = 'info', // success | warning | danger...
public bool $cerrable = true,
) {}
public function render(): View|Closure|string
{
return view('components.alerta');
}
}<!-- resources/views/components/alerta.blade.php -->
<div {{ $attributes->merge(['class' => "alert alert-{$tipo}"]) }}>
@if ($cerrable)
<button type="button" class="btn-close float-end"></button>
@endif
{{ $slot }}
</div>El stock quedará en cero.
</x-alerta>
Léelo así: atributos del tag → constructor ($tipo); el CONTENIDO entre abrir y
cerrar llega como $slot; $attributes->merge() combina la
clase base con cualquier extra (class="mt-3") sin perder nada. Los tags
Componentes anónimos: sin clase
Piezas pequeñas no necesitan clase PHP: un archivo suelto en resources/views/components/ basta:
<!-- resources/views/components/estado.blade.php -->
@props(['estado' => 'REGISTRADO'])
<span {{ $attributes->merge(['class' => "badge text-bg-{etiqueta_estado($estado)}"]) }}>
{{ $estado }}
</span><!-- uso en cualquier vista -->
<x-estado :estado="$p['estado']" />
<!-- :estado = expresión PHP evaluada -->
<!-- estado = string literal "$p['estado']" -->El badge del capítulo 9 queda refactorizado a UNA etiqueta reutilizable en TODO el manual — listados, dashboard, API docs internas. Con dos puntos se pasa valor dinámico; sin ellos, texto plano. Esa distinción te ahorrará horas de confusión.
Includes, each y stacks
| Herramienta | Sirve para | Ejemplo |
|---|---|---|
| @include | parcial con datos explícitos | @include('pedidos.fila', ['p' => $p]) |
| @each | parcial por elemento + vacío | @each('pedidos.fila', $pedidos, 'p', 'pedidos.vacia') |
| @push/@stack | CSS/JS que SOLO cierta página carga | visto en el layout de arriba |
| @once | empujar UNA vez aunque el parcial se repita | @once @push('scripts') … @endonce @endpush |
Y una joyita para clases condicionales sin if-feos:
<tr @class(['fila' => true, 'activa' => $loop->even])>.
¿Cuál usar?
| Situación | Herramienta |
|---|---|
| esqueleto global simple, pocas páginas | @extends/@yields |
| piezas con lógica/props reutilizables | componente con clase |
| badges, iconos, tarjetas simples | componente anónimo |
| fragmento tonto repetido | @include/@each |
En Pedidos combinaremos: layout clásico para el esqueleto + componentes anónimos para estado, tabla de pedidos y formularios. Lo verás ensamblado en la Parte IV.
Puntos clave
- @extends/@yield = nuestro layout() artesanal, compilado y cacheado.
- Componente: atributos→constructor, contenido→$slot, merge() preserva extras.
- Anónimo = solo .blade.php en components/; @props tipa sus datos.
- :prop evalúa PHP; prop sin dos puntos es literal.
- @push/@stack cargan assets por página; @once evita duplicados.
11 · Middleware: capas sobre la petición
Intermedio ~14 minCuando el MVC chequeaba la sesión al inicio del front controller, estábamos escribiendo un middleware a mano sin saberlo. Un middleware es una CAPA por la que toda petición (o un grupo) atraviesa antes de llegar a tu código — y puede actuar en la ida, en la vuelta, o ambas.
- Crear middleware con make:middleware y entender handle() + $next().
- Registrarlos en bootstrap/app.php: global, grupos y alias.
- Distinguir global / grupo / ruta con ejemplos reales de Pedidos.
- Usar el atributo #[Middleware] de Laravel 13 junto al controlador.
Anatomía: handle() y la cebolla
# → app/Http/Middleware/FirmarRespuesta.php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class FirmarRespuesta
{
public function handle(Request $request, Closure $next): Response
{
// ANTES: aquí se bloquea, redirige, enriquece la request...
$respuesta = $next($request); // «sigue hacia adentro»
// DESPUÉS: ya hay respuesta del resto de la app
$respuesta->headers->set('X-Pedidos-Firma', 'webcode');
return $respuesta;
}
}$next($request) es el pasamanos hacia la siguiente capa; lo que hagas ANTES de esa línea corre en la ida (auth, CORS, logs), lo que hagas DESPUÉS toca la respuesta (headers, cifrado). El orden importa: los middlewares forman una cebolla y tu capa queda donde la registres.
Registro moderno: todo vive en bootstrap/app.php
Otro cambio grande del esqueleto nuevo (cap. 3): no hay Http/Kernel.php. Los middlewares se registran configurando el objeto Middleware:
<?php
// bootstrap/app.php
use App\Http\Middleware\FirmarRespuesta;
use App\Http\Middleware\EsAdmin;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(/* ... */)
->withMiddleware(function (Middleware $middleware) {
// GLOBAL: TODAS las peticiones (id y/o vuelta)
$middleware->append(FirmarRespuesta::class);
// ALIAS: nombre corto para usar en rutas
$middleware->alias(['admin' => EsAdmin::class]);
})
->withExceptions()->create();Los GRUPOS ya vienen cargados: web (sesión, cookies, protección
CSRF — desde Laravel 13 el middleware se llama
PreventRequestForgery) y api (stateless + throttle).
routes/web.php usa web; routes/api.php usa api — por eso el CSRF «aparece solo» en
formularios web y jamás estorba a la API.
Global, grupo o ruta: quién protege qué
| Nivel | Se aplica a… | Ejemplo Pedidos |
|---|---|---|
->append() | absolutamente todo | firma de respuesta, logs de performance |
| grupo web/api | toda la zona correspondiente | sesión+CSRF vs stateless |
->middleware('alias') | rutas específicas | Route::get(...)->middleware('admin') |
| grupo propio | módulo entero | Route::middleware('admin')->group(...) |
La regla práctica: si algo debe cumplirlo TODO, global; si delimita una ZONA, grupo; si es caso puntual, alias en la ruta.
Novedad 13: el middleware vive junto al controlador
La promesa del capítulo 8, cumplida — además de registrar por ruta, puedes declararlo como atributo PHP directamente sobre la clase o el método:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Routing\Attributes\Controllers\Middleware;
use Illuminate\Routing\Attributes\Controllers\WithoutMiddleware;
#[Middleware('web')] // clase entera
class PedidoControlador extends Controller
{
#[Middleware('admin')] // solo este método
public function destroy(string $id) {}
#[WithoutMiddleware(FirmarRespuesta::class)]
public function healthcheck() {}
}Ventajas: la intención viaja con el código (abres el controlador y VES sus capas),
con only/except para afinar. El registro clásico en rutas sigue siendo igual de válido —
usa uno y sé consistente. Cuando montemos sesión (cap. 31), protegerá así:
#[Middleware('auth')].
php artisan route:list -v muestra la pila completa de middlewares POR
ruta — la cebolla hecha lista, en el orden exacto de ejecución.Puntos clave
- handle(Request, Closure $next): antes = ida; después = vuelta.
- Sin Kernel.php: append()/alias() en bootstrap/app.php.
- Grupos web (sesión+CSRF) vs api (stateless): zonas ya separadas.
- Global = todo; grupo = zona; alias = ruta puntual.
- 13: atributos #[Middleware]/#[WithoutMiddleware] junto al método.
12 · Formularios: CSRF y validación
Intermedio ~18 minEl formulario de alta de cliente del MVC lo escribimos con las tres piezas sagradas: token oculto anti-CSRF, validación a mano campo por campo y repintado con los valores previos. Laravel conserva las tres — pero cada una pasa de «disciplina manual» a «mecánica del framework». Este capítulo las conecta.
- Escribir el formulario mínimo seguro: @csrf, @method, old().
- Validar con $request->validate() y reglas declarativas.
- Mostrar errores por campo con @error y el $errors bag.
- Traducir mensajes al español y conocer las reglas más frecuentes.
El formulario mínimo seguro
<!-- resources/views/clientes/crear.blade.php -->
<form method="POST" action="/clientes">
@csrf <!-- genera <input type="hidden" name="_token" ...> -->
<input name="nombre" value="{{ old('nombre') }}">
@error('correo')
<p class="text-danger small">{{ $message }}</p>
@enderror
<input name="correo" value="{{ old('correo') }}">
<button type="submit">Guardar cliente</button>
</form>Tres traducciones directas. El token oculto manual → @csrf; sin él,
PreventRequestForgery (cap. 11) rechaza el POST con 419 Page Expired. Los values que
repintábamos con $_POST → helper old(). Y @error('campo') lee
del bolso de errores que veremos enseguida.
Bonus para edición: HTML solo conoce GET/POST; para PUT/DELETE se simula el verbo
con @method('PUT') dentro del formulario — Laravel lo reescribe antes de
enrutar (nuestro truco del hidden _method del MVC, oficializado).
Validar: una llamada, todo incluido
<?php
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
Route::post('/clientes', function (Request $request) {
$validos = $request->validate([
'nombre' => ['required', 'string', 'max:80'],
'correo' => ['required', 'email', 'unique:clientes,correo'],
'estado' => ['required', Rule::in(config('pedidos.estados'))],
]);
// aquí SOLO llegan los campos validados, ya limpios
logger('Cliente válido', $validos);
});validate() tiene DOS salidas posibles y ambas automáticas:
- Pasa: devuelve el arreglo con SOLO los campos validados.
- Falla: lanza ValidationException — el framework redirige hacia atrás con los errores + old() poblados si venía de web, o responde 422 + JSON de errores si la petición esperaba JSON. Un método, dos mundos.
-H "Accept: application/json" -d "nombre=&correo=malo"
# sin Accept: application/json habría redirect 302 con errores en sesión
Las reglas que usarás el 90% del tiempo
| Regla | Exige… | Ojo con… |
|---|---|---|
| required / nullable | obligatorio / puede venir vacío | null ≠ '' : decide por campo |
| string / integer / numeric | tipo del valor | integer NO recorta decimales |
| min:x / max:x | mínimo/máximo | strings = caracteres; números = VALOR (usa digits:x) |
| email / date / url | formato válido | date acepta lo parseable por strtotime |
| confirmed | campo_confirmation igual | típico en password |
| unique:tabla,col | no exista aún | al EDITAR: Rule::unique()->ignore($id) |
| exists:tabla,col | sí exista (FK lógica) | nuestro pedido_detalles.producto_id |
| in:a,b,c | valor de la lista | mejor Rule::in(config(...)) |
| bail | parar al primer fallo del campo | mensajes más cortos |
Nótese Rule::in(config('pedidos.estados')): la configuración del
capítulo 5 evita escribir REGISTRADO,PAGADO,ANULADO a mano — una sola fuente de verdad,
como el ENUM del esquema.
Mensajes en español
Laravel habla inglés por defecto. Dos pasos para hispanizarlo:
APP_LOCALE=es
y crear lang/es/validation.php con los mensajes (los mantiene la comunidad en el repositorio laravel-lang). Personaliza atributos y mensajes puntuales:
<?php
// lang/es/validation.php (fragmento)
return [
'required' => 'El campo :attribute es obligatorio.',
'email' => 'El campo :attribute debe ser un correo válido.',
'attributes' => [
'correo' => 'correo electrónico',
],
];Los dos puntos :attribute/:max son marcadores que el validador sustituye — mismo patrón que nuestros sprintf() de mensajes en el MVC.
php artisan make:request GuardarCliente) — clase
con authorize() + rules() que se inyecta por type-hint en lugar de Request. Mismo
mecanismo, mejor organización. Lo usaremos en la Parte IV.Puntos clave
- @csrf obligatorio en TODO form POST web; falta = 419 automático.
- @method('PUT') simula verbos que HTML no conoce.
- validate(): pasa→arreglo limpio; falla→redirect+old o 422 JSON.
- @error('campo') + {{ $message }} pintan el error junto al input.
- APP_LOCALE=es + lang/es/validation.php = mensajes al español.
13 · Sesiones y mensajes flash
Básico ~14 minEl MVC abría con session_start() y leía $_SESSION a
pelo. Laravel envuelve la misma idea en un objeto con drivers intercambiables —
y por defecto usa TU BASE DE DATOS: la tabla sessions que verás nacer en el
próximo capítulo ya está declarada en las migraciones que trae el framework.
- Leer/escribir sesión con request->session() y el helper session().
- Dominar los datos flash: viven UNA petición, ideales para avisos.
- Encadenar redirect()->with(...) y pintar el aviso en la vista.
- Saber qué hace regenerate() y por qué importa al iniciar sesión.
La API básica
<?php
use Illuminate\Http\Request;
Route::post('/carrito/agregar', function (Request $request) {
// escribir
$request->session()->put('carrito.7', ['cant' => 2]);
$request->session()->push('historial', '/producto/7');
// leer (helper equivalente al objeto completo)
$carrito = session('carrito'); // arreglo entero
$ultimo = session('historial.0'); // dot-notation también lee
$hay = $request->session()->has('carrito');
// quitar
$viejo = $request->session()->pull('carrito.7'); // lee Y borra
$request->session()->forget('historial'); // una clave
$request->session()->flush(); // TODO fuera
});| Eco del MVC | Aquí |
|---|---|
| $_SESSION['x'] = $v; | session(['x' => $v]) o put() |
| isset($_SESSION['x']) | session()->has('x') |
| $v = $_SESSION['x']; unset(...) | session()->pull('x') |
| session_destroy() | session()->flush() (+ invalidate()) |
Flash: datos con fecha de vencimiento
El patrón POST-redirect-GET deja un aviso para la PÁGINA SIGUIENTE. Esos son datos flash: sobreviven exactamente una petición y desaparecen solos — sin limpiarlos a mano como hacíamos antes de redirigir:
<?php
Route::get('/demo/flash', fn () =>
redirect('/demo/mensaje')->with('aviso', 'Pedido guardado con éxito'));
Route::get('/demo/mensaje', function () {
return 'Aviso: '.session('aviso', '(nada)');
});->with('clave', 'valor') es azúcar para flash(). La prueba con cookie
de sesión (curl necesita conservarla):
curl -s -b galletas.txt http://127.0.0.1:8000/demo/mensaje
curl -s -b galletas.txt http://127.0.0.1:8000/demo/mensaje
Primera lectura lo muestra; al cerrar esa respuesta el framework lo descarta.
Variantes: ->session()->now('aviso', ...) para la petición ACTUAL (sin
redirect) y reflash()/keep() para estirarle la vida un round más.
Pintarlo en la vista
@if (session('aviso'))
<x-alerta tipo="success">{{ session('aviso') }}</x-alerta>
@endif
{{-- componente x-alerta creado en el capítulo 10 -->Colocado en el layout, CUALQUIER controlador puede lanzar avisos sin tocar HTML — el formulario del capítulo 12 ya dejaba errores y old() listos para este baile.
El driver database y la regeneración
Nuestro .env dice SESSION_DRIVER=database: cada sesión es una fila en
sessions (id, payload cifrado, expiración, usuario). Ventajas frente a archivos: sesiones
sobreviven despliegues multi-servidor y puedes auditarlas con SQL.
$request->session()->regenerate() — id nuevo tras autenticar, para que
un atacante no herede la sesión pre-login (fijación de sesión). Anótalo: es LA razón
por la que las apps serias nunca confían solo en cookies viejas.Puntos clave
- session()/request->session(): put, get, has, pull, forget, flush.
- flash = vive UNA petición; with() en redirect es su azúcar.
- now() para la petición actual; reflash/keep extienden un round.
- Driver database: fila en tabla sessions — auditable y multi-servidor.
- regenerate() al autenticar: anti fijación de sesión (cap. 31).
14 · Conexión a la base de datos: MariaDB
Básico ~13 minAbrimos la Parte III. Laravel arranca con sqlite para que NADA te frene el primer día — pero Pedidos es un dominio de verdad y tienda_orm vivió siempre en MariaDB. Cambiar el motor es tocar UN archivo que ya conoces del capítulo 5: .env.
- Crear la base pedidos_laravel y un usuario DEDICADO sin privilegios de más.
- Apuntar .env a MariaDB entendiendo qué lee config/database.php.
- Verificar la conexión con db:show y una consulta desde tinker.
- Recordar la regla del cap. 5: tocó .env → toca config:clear si hay caché.
Base y usuario: mínima privilegiada
El usuario solo existe PARA esta base — jamás root en el .env, igual que en los manuales anteriores. Las migraciones (cap. 15) crearán las tablas; la base nace vacía a propósito: el ESQUEMA será código, no un dump SQL.
El .env que cambia todo
# .env — antes: DB_CONNECTION=sqlite (sin más claves)
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=pedidos_laravel
DB_USERNAME=pedidos_app
DB_PASSWORD=clave-fuerte-aqui
SESSION_DRIVER=database ← ya estaba: ahora usa TU MariaDB
QUEUE_CONNECTION=database ← ídem (cap. 35)¿Y quién lee esto? El bloque connections.mysql de config/database.php:
<?php
// config/database.php (fragmento)
'connections' => [
'mysql' => [
'driver' => 'mysql',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
// ...
],
],La misma cadena env()-dentro-de-config/ del capítulo 5. Charset utf8mb4 por defecto: acentos y emojis seguros sin configurar nada — el problema clásico de los proyectos PDO artesanales resuelto de fábrica.
Verificar: dos pruebas de fuego
php artisan db:show
# 2. Consulta viva desde tinker
php artisan tinker
Cero tablas y conexión viva: exactamente donde queríamos. La fachada DB es la puerta de entrada al motor (el query builder completo merece su propio momento); por hoy basta saber que responde.
php artisan config:clear y reintenta. Síntoma típico: «Access denied for
user@localhost» cuando las credenciales están bien escritas.Sobre sqlite: no lo borres ni desprecies — tests rápidos y prototipos vuelan con él, y en el cap. 40 veremos que Pest puede usar sqlite EN MEMORIA mientras producción usa MariaDB. Dos motores, cero código cambiado: eso es abstraer la conexión bien hecho.
Puntos clave
- CREATE DATABASE + usuario dedicado con GRANT SOLO sobre esa base.
- .env guarda valores; config/database.php estructura vía env().
- db:show y DB::select() confirman la conexión antes de seguir.
- utf8mb4 default: acentos/emojis sin drama de collation.
- .env + config:cache vieja = credenciales fantasma: config:clear.
15 · Migraciones I: el esquema como código
Intermedio ~16 mintienda_orm existía como archivo .sql versionado a mano: cambiar una columna era editar el dump, rezar y re-ejecutar en cada máquina. Una migración es ese cambio ESCRITO EN PHP — versionado, reversible y ejecutado en orden por el framework. El esquema deja de ser un artefacto aparte: vive en tu repositorio.
- Generar y entender una migración con make:migration.
- Traducir columnas de tienda_orm a métodos de Blueprint.
- Ejecutar migrate y leer la tabla bookkeeping migrations.
- Revertir con rollback y auditar con migrate:status.
La primera migración
# → database/migrations/2026_08_24_101500_create_clientes_table.php
El nombre importa: create_X_table le dice al generador que la tabla se llamará X. El prefijo fecha garantiza ORDEN de ejecución — las FK del capítulo 16 dependerán de que clientes exista primero.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('clientes', function (Blueprint $table) {
$table->id('cliente_id'); // INT UNSIGNED AI PK
$table->string('nombre', 100); // VARCHAR(100)
$table->string('correo', 120)->unique(); // UNIQUE KEY
$table->string('telefono', 20)->nullable(); // NULL permitido
$table->timestamp('fecha_registro')->useCurrent();
});
}
public function down(): void
{
Schema::dropIfExists('clientes');
}
};Diccionario Blueprint ↔ SQL
| Blueprint | SQL equivalente |
|---|---|
| $table->id('cliente_id') | BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY |
| string('nombre', 100) | VARCHAR(100) NOT NULL |
| ->unique() / ->nullable() | UNIQUE / NULL (modificadores encadenables) |
| ->default(0) | DEFAULT 0 |
| timestamp()->useCurrent() | DATETIME DEFAULT CURRENT_TIMESTAMP |
| $table->timestamps() | created_at + updated_at (convención moderna) |
Decisión deliberada: clientes conserva fecha_registro porque así vive en tienda_orm — espejo exacto del esquema original. Para productos y pedidos (cap. 16) usaremos timestamps(), la convención del framework. Saber CUÁNDO espejar y cuándo adoptar la convención es parte del oficio; aquí te lo muestro explícito.
Ejecutar y llevar la contabilidad
Sorpresa productiva: las TRES primeras migraciones venían de fábrica — users, password_reset_tokens y sessions (¡la tabla del cap. 13!), más cache y jobs para los capítulos 35/37. El framework deja su casa en orden antes de tocar la tuya.
¿Cómo sabe migrate qué falta? Tabla migrations: una fila por archivo con su lote (batch). Revertir un batch = ejecutar los down() en orden inverso:
php artisan migrate:rollback # revierte SOLO el último batch
php artisan migrate # vuelve a aplicar
Puntos clave
- Esquema en PHP: up() construye, down() deshace; versionado en git.
- Prefijo fecha = orden garantizado; create_X_table deduce la tabla.
- Tabla migrations registra archivos + batch: rollback por lotes.
- Migraciones default crean users/sessions/cache/jobs de fábrica.
- Ya aplicada = intocable; cambios nuevos van en migraciones nuevas.
16 · Migraciones II: llaves foráneas y familia
Intermedio ~17 minclientes ya vive en el motor. Ahora la familia completa — productos, pedidos y pedido_detalles con sus llaves foráneas — donde cada decisión de borrado cuenta una regla de negocio. Al terminar, pedidos_laravel será el gemelo fiel de tienda_orm, pero nacido de código versionado.
- Crear las tres tablas restantes con sus FKs y decisiones ON DELETE.
- Congelar precios en pedido_detalles: DECIMAL, nunca float.
- Usar timestamps() donde tienda_orm lo permitía.
- Dominar la familia migrate: fresh, rollback --step, reset.
productos: catálogo simple
<?php
// up() de create_productos_table
Schema::create('productos', function (Blueprint $table) {
$table->id('producto_id');
$table->string('nombre', 120);
$table->decimal('precio', 10, 2); // ¡dinerito: DECIMAL!
$table->unsignedInteger('stock')->default(0);
$table->boolean('activo')->default(true);
$table->timestamps(); // created_at + updated_at
});decimal(10, 2): hasta 99 999 999.99 exactos. Regla de casa desde el
manual Eloquent — el dinero NUNCA en float ni double: los errores de redondeo no son
opinables, son aritmética binaria.
pedidos: la cabecera con su FK
<?php
Schema::create('pedidos', function (Blueprint $table) {
$table->id('pedido_id');
$table->unsignedBigInteger('cliente_id');
$table->foreign('cliente_id')
->references('cliente_id')->on('clientes')
->restrictOnDelete(); // sin cliente vivo, sin borrado
$table->decimal('total', 10, 2)->default(0);
$table->string('estado', 10)->default('REGISTRADO');
$table->timestamp('fecha_pedido')->useCurrent();
$table->index('estado'); // listados filtran por estado a diario
});¿Por qué restrictOnDelete? Porque el dominio ya decidió (manuales anteriores): un cliente NO se borra si tiene pedidos — se ANULA el pedido (estado ANULADO). La integridad no es opcional ni del controlador: la impone el motor.
pedido_detalles: dos mundos de borrado
<?php
Schema::create('pedido_detalles', function (Blueprint $table) {
$table->id('detalle_id');
$table->unsignedBigInteger('pedido_id');
$table->unsignedBigInteger('producto_id');
// el detalle muere con su pedido:
$table->foreign('pedido_id')->references('pedido_id')
->on('pedidos')->cascadeOnDelete();
// pero jamás arrastrará al producto:
$table->foreign('producto_id')->references('producto_id')
->on('productos')->restrictOnDelete();
$table->unsignedSmallInteger('cantidad');
$table->decimal('precio_unitario', 10, 2); // ¡precio CONGELADO!
});Ese precio_unitario es LA razón de ser de esta tabla: si mañana el producto cuesta S/ 15.90, los pedidos viejos deben seguir cuadrando con el S/ 12.50 de entonces. El histórico es un documento, no una vista del catálogo. InnoDB crea índices para las columnas FK automáticamente.
La familia completa de comandos
| Comando | Hace | Cuándo |
|---|---|---|
| migrate | aplica pendientes | siempre, primero |
| migrate:fresh | DROP ALL + re-ejecuta TODO | dev temprano (--seed en cap. 18) |
| migrate:rollback --step=1 | deshace último batch | te equivocaste recién |
| migrate:reset | revierte todo histórico | raro; casi siempre prefieres fresh |
| migrate:status | ran? / pendientes / batches | auditar antes de desplegar |
migrate --force dentro de una ventana de mantenimiento — protocolo
completo en el cap. 43.Verificación final del espejo: php artisan db:show ahora reporta
8 tablas (4 del dominio + users/sessions/cache/jobs). tienda_orm renació como código.
Puntos clave
- FK = regla de negocio en el motor: restrict al cliente/producto, cascade al detalle.
- Dinero SIEMPRE decimal(x,2); precio_unitario congela el histórico.
- timestamps() donde convención cabe, espejo exacto donde el legado manda.
- index() en columnas muy filtradas (estado).
- fresh solo dev; producción: migrate --force bajo ventana (cap. 43).
17 · Modelos Eloquent con make:model
Intermedio ~16 minEn php_03_eloquent escribimos cada clase modelo a mano: tabla, llave, fechas. El generador hace el andamiaje en un comando — y Laravel 13 añade algo nuevo que ya verificamos en la documentación oficial: los mismos ajustes pueden vivir como ATRIBUTOS PHP sobre la clase. Aquí quedan los cuatro modelos de Pedidos.
- Generar modelos y entender las banderas -m/-f/-s/-c (y el combo -mfsc).
- Configurar tabla, PK y fechas: propiedades clásicas vs atributos 13.
- Blindar contra mass assignment con $fillable.
- Mapear tipos reales con $casts — dinero decimal, jamás float.
make:model y sus banderas
# → app/Models/Cliente.php
# → database/factories/ClienteFactory.php (cap. 18)
# → database/seeders/ClienteSeeder.php (cap. 18)
| Bandera | Genera además |
|---|---|
| -m / --migration | migración nueva |
| -f / --factory | factory para datos de prueba |
| -s / --seed | seeder |
| -c / --controller | controlador |
| -mfsc (combo) | las cuatro juntas — típico en proyecto NUEVO |
Nosotros pasamos por partes a propósito (migraciones ya existen desde los caps. 15–16), así que solo -f y -s. En un proyecto verde usarías -mfsc y todo nace junto.
El modelo configurado: dos estilos oficiales
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Cliente extends Model
{
// convención acierta: Cliente → tabla "clientes" (ni $table hace falta)
protected $primaryKey = 'cliente_id'; // no es "id": hay que decirlo
public $timestamps = false; // fecha_registro la pone MySQL
protected $fillable = ['nombre', 'correo', 'telefono'];
protected $casts = [
'fecha_registro' => 'datetime',
];
}#[Table(key: 'cliente_id', timestamps: false)],
#[Fillable(['nombre', ...])] (namespace
Illuminate\Database\Eloquent\Attributes). Conviven con las propiedades clásicas;
este manual usa las clásicas porque son las que conoces del manual Eloquent —
pero ya sabes leer el otro estilo cuando aparezca en código ajeno.Los otros tres modelos, en una pizca:
<?php
// Producto: timestamps() SÍ existe en su migración → sin tocar fechas
class Producto extends Model
{
protected $primaryKey = 'producto_id';
protected $fillable = ['nombre', 'precio', 'stock', 'activo'];
protected $casts = [
'precio' => 'decimal:2', // dinero: string exacto, NO float
'activo' => 'boolean',
];
}
// Pedido y PedidoDetalle: mismo patrón, PK propia + fillable + casts
// (sus relaciones llegan en el capítulo 19)$fillable: el guardia de mass assignment
Si create()/update() aceptaran CUALQUIER campo, un usuario malicioso enviaría
total=0 o estado=PAGADO metido en el POST y el framework,
obediente, lo guardaría. $fillable es la LISTA BLANCA: todo lo demás se descarta en
silencio al hacer asignación masiva:
Eco directo de nuestro insert artesanal con arreglo blanco explícito — misma idea,
ahora obligatoria por diseño. Tip dev: Model::preventSilentlyDiscardingAttributes(!app()->isProduction())
en AppServiceProvider grita en local cuando un campo se descarta.
$casts y el inspector de modelos
La BD entrega strings; $casts los traduce al tipo honesto al LEER y al ESCRIBIR: decimal:2 devuelve "129.90" exacto (nunca 129.89999...), boolean da true/false reales, datetime da Carbon con toda su artillería de fechas. Y para auditar un modelo sin abrir la BD:
Los cuatro modelos respiran sobre las cuatro tablas. Les faltan dos órganos: los DATOS (factories/seeders — capítulo siguiente) y las RELACIONES (cap. 19).
Puntos clave
- make:model genera andamio; -mfsc es el combo de proyecto nuevo.
- $primaryKey/$timestamps cuando tu esquema no usa defaults de Eloquent.
- Atributos 13 (#Table/#Fillable) = alternativa oficial a propiedades.
- $fillable lista blanca anti mass assignment; casts traducen tipos.
- Dinero decimal:2 (exacto); model:show audita sin SQL.
18 · Factories y seeders: datos de prueba
Intermedio ~15 minEn el manual Eloquent escribimos los datos de práctica como arreglos fijos en módulos propios. Laravel formaliza la misma idea en dos piezas: FACTORIES (el molde por modelo) y SEEDERS (el guion que llena la base). Y aquí con una regla estricta del manual: datos DETERMINISTAS — los mismos nombres y montos en cada ejecución, para que las salidas de este libro siempre cuadren.
- Rellenar la factory generada en el cap. 17 con definition() y sequence().
- Crear estados alternativos (estados de pedido) con states().
- Encadenar seeders desde DatabaseSeeder y correrlos.
- Reiniciar todo limpio con migrate:fresh --seed.
La factory: molde + variaciones
<?php
// database/factories/ClienteFactory.php (generada con make:model -fs)
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
class ClienteFactory extends Factory
{
public function definition(): array
{
// valores BASE; sequence() los sobreescribe uno a uno
return [
'nombre' => 'Cliente sin nombre',
'correo' => 'sin-correo@ejemplo.pe',
'telefono' => '999000000',
];
}
}sequence(...) entrega el i-ésimo arreglo al i-ésimo registro: tres filas,
ids 1-3, SIEMPRE iguales. ¿Y faker con datos aleatorios? Existe y es útil para volumen,
pero este manual lo evita: una salida distinta por ejecución mata la reproducibilidad
de ejemplos y tests.
Estados: variantes con nombre
<?php
// database/factories/PedidoFactory.php — estados de nuestro dominio
public function definition(): array
{
return [
'total' => 0,
'estado' => 'REGISTRADO',
];
}
public function pagado(): static
{
return $this->state(fn () => ['estado' => 'PAGADO']);
}
public function anulado(): static
{
return $this->state(fn () => ['estado' => 'ANULADO']);
}Pedido::factory()->pagado()->create()
Los tres estados del ENUM (REGISTRADO/PAGADO/ANULADO) ahora son métodos encadenables — la configuración del cap. 5 y la validación del cap. 12 hablan el mismo idioma que las factories. Coherencia de dominio de punta a punta.
Seeders: el guion completo
<?php
// database/seeders/ProductoSeeder... ProductoSeeder.php
public function run(): void
{
\App\Models\Producto::factory()->count(4)->sequence(
['nombre' => 'Teclado mecánico', 'precio' => 129.90, 'stock' => 15],
['nombre' => 'Mouse inalámbrico', 'precio' => 45.50, 'stock' => 30],
['nombre' => 'Monitor 24"', 'precio' => 389.00, 'stock' => 8],
['nombre' => 'Hub USB-C', 'precio' => 62.85, 'stock' => 20],
)->create();
}<?php
// database/seeders/DatabaseSeeder.php — el director
public function run(): void
{
$this->call([
ClienteSeeder::class,
ProductoSeeder::class,
]);
}# o el combo de desarrollo diario:
php artisan migrate:fresh --seed
Estado actual: 3 clientes y 4 productos esperando. Los pedidos quedan vacíos a propósito — primero necesitan RELACIONES (cap. 19) y su primera venta será una TRANSACCIÓN de verdad (cap. 21).
Puntos clave
- definition() = molde base; sequence() = datos fijos por posición.
- Determinismo primero: sin faker cuando la salida debe repetirse.
- states() nombran variantes del dominio: ->pagado(), ->anulado().
- DatabaseSeeder dirige; db:seed o fresh --seed ejecutan.
- Seeders = dev/staging/tests, nunca producción.
19 · Relaciones y el asesino N+1
Intermedio ~17 minEn el manual Eloquent declaramos belongsTo y hasMany a mano sobre los modelos. Aquí lo mismo — con una sorpresa agradable: por la disciplina de nombres que heredamos de tienda_orm (cliente_id, pedido_id…), TODAS las relaciones quedan en cero argumentos. Y cerramos la Parte III sembrando pedidos reales.
- Declarar las 5 relaciones del dominio sin argumentos gracias a las convenciones.
- Navegarlas: $pedido->cliente->nombre, $detalle->producto->precio.
- Entender el N+1 y eliminarlo SIEMPRE con with() (regla de casa).
- Sembrar 3 pedidos con detalles usando relaciones + estados.
Las cinco relaciones
<?php
// App\Models\Pedido
public function cliente(): BelongsTo
{
return $this->belongsTo(Cliente::class); // FK cliente_id deducida
}
public function detalles(): HasMany
{
return $this->hasMany(PedidoDetalle::class); // pedido_id deducida
}
// App\Models\PedidoDetalle
public function producto(): BelongsTo
{
return $this->belongsTo(Producto::class);
}
public function pedido(): BelongsTo
{
return $this->belongsTo(Pedido::class);
}
// App\Models\Cliente
public function pedidos(): HasMany
{
return $this->hasMany(Pedido::class); // busca cliente_id solo
}¿Por qué sin argumentos? belongsTo(Cliente) asume FK = cliente_id (nombre del modelo + _id) y como ownerKey toma la PK DECLARADA en el modelo Cliente (cliente_id). Las convenciones trabajaron gratis dos capítulos seguidos — esto es lo que se gana al nombrar bien desde el esquema.
Navegación natural
El asesino N+1
<?php
// CÓMO NO: consulta por cada fila!
foreach (Pedido::all() as $pedido) {
echo $pedido->cliente->nombre; // lazy loading: 1 query por iteración
}
// 100 pedidos = 1 + 100 queries. En producción: página muerta.
// LA REGLA DE CASA: eager loading con with()
foreach (Pedido::with('cliente')->get() as $pedido) {
echo $pedido->cliente->nombre;
}
// exactamente 2 queries, carguen 10 o 10 000 filasAnidado para el detalle completo: Pedido::with('detalles.producto') trae
cabeceras + líneas + catálogo en 3 consultas planas. Y contadores sin traer filas:
Pedido::withCount('detalles')->get() añade columna
detalles_count.
Hacer que el N+1 EXPLOTE en desarrollo
<?php
// app/Providers/AppServiceProvider.php — boot()
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading(! $this->app->isProduction());
}Con eso, un lazy load olvidado lanza excepción EN LOCAL (lo ves al instante) pero sigue funcionando en producción (no tumbras la tienda por un descuido). El framework vigilando tu regla de casa favorita.
Cerrar la Parte III: sembrar pedidos
<?php
// database/seeders/PedidoSeeder.php (esencia)
$ana = Cliente::find(1);
$luis = Cliente::find(2);
// Pedido 1: Ana, PAGADO, dos líneas
$p1 = Pedido::factory()->pagado()->for($ana)->create();
foreach ([['producto_id' => 1, 'cantidad' => 2], ['producto_id' => 2, 'cantidad' => 1]] as $linea) {
$prod = Producto::find($linea['producto_id']);
$p1->detalles()->create([
'producto_id' => $prod->producto_id,
'cantidad' => $linea['cantidad'],
'precio_unitario' => $prod->precio, // precio CONGELADO
]);
}
$p1->load('detalles'); // eager para no caer en N+1
$p1->update(['total' => $p1->detalles->sum(
fn ($d) => $d->cantidad * $d->precio_unitario
)]);
// ... pedidos 2 (Luis REGISTRADO) y 3 (Ana ANULADO) igualphp artisan tinker
Parte III completa: esquema versionado, modelos blindados, datos deterministas y relaciones navegables. La Parte IV construye el CRUD web sobre este cimiento.
Puntos clave
- Nombres disciplinados = relaciones sin argumentos.
- JAMÁS consultar dentro de loop: with() siempre (2 queries totales).
- Anida puntos: with('detalles.producto'); withCount() para totales.
- preventLazyLoading(!isProduction()): N+1 explota en dev, no en prod.
- ->for($cliente) conecta belongsTo desde factories.
20 · Listado y detalle: route model binding
Intermedio ~16 minAbre la Parte IV: el CRUD web de Pedidos. Primera parada, las dos operaciones de lectura. En el MVC hacíamos ver($id) → buscar fila → if (!fila) 404 a mano. Aquí el framework hace TODO eso por el nombre del parámetro — y la vista reutiliza los componentes y helpers que venimos sembrando desde el capítulo 10.
- Crear el controlador Web y conectarlo con Route::resource parcial.
- Entender el binding implícito y ajustar la ruta-key al esquema legacy.
- Listar con eager loading (regla N+1) y pintar con x-estado + moneda().
- Detalle completo: cabecera, líneas y total — 3 consultas planas.
Controlador web y rutas parciales
# namespace App\Http\Controllers\Web
<?php
// routes/web.php
use App\Http\Controllers\Web\PedidoControlador;
Route::resource('pedidos', PedidoControlador::class)
->only(['index', 'show']); // alta/edición llegan en caps 21-22Solo/index y show por ahora: only()/except() recortan el resource sin perder nombres ni convenciones (documentado oficialmente). El resto de métodos esperará su capítulo.
Binding implícito y la clave del esquema viejo
<?php
// App\Models\Pedido — una línea para heredar esquema legacy
public function getRouteKeyName(): string
{
return 'pedido_id'; // binding busca POR ESTA columna
}<?php
// App\Http\Controllers\Web\PedidoControlador
public function show(Pedido $pedido) // {pedido} de la URI
{
$pedido->load(['cliente', 'detalles.producto']);
return view('pedidos.detalle', ['pedido' => $pedido]);
}
public function index()
{
return view('pedidos.lista', [
'pedidos' => Pedido::with('cliente') // regla de casa
->orderByDesc('fecha_pedido')
->get(),
]);
}El type-hint Pedido $pedido coincide con el parámetro {pedido}: Laravel busca la fila y si no existe responde 404 SIN que escribas un solo if — nuestro findOrFail() del manual Eloquent convertido en magia declarativa. Con PK estándar id ni getRouteKeyName() haría falta; nuestro pedido_id heredado exige la línea extra.
La vista listado: todo lo anterior cobra fruto
<!-- resources/views/pedidos/lista.blade.php -->
@extends('layouts.app')
@section('contenido')
<h1>Pedidos</h1>
<table>
@forelse ($pedidos as $p)
<tr>
<td><a href="{{ route('pedidos.show', $p) }}">#{{ $p->pedido_id }}</a></td>
<td>{{ $p->cliente->nombre }}</td> {{-- eager: sin N+1 --}}
<td><x-estado :estado="$p->estado" /></td>
<td>{{ moneda($p->total) }}</td>
</tr>
@empty
<p>Aún no hay pedidos.</p>
@endforelse
</table>
@endsection
{{-- x-estado = componente anónimo del capítulo 10 -->curl -s http://127.0.0.1:8000/pedidos | grep -E 'href|badge|S/' | head -n 4
El detalle y sus tres consultas
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/pedidos/999
El detalle muestra cabecera (cliente, estado, fecha) y tabla de líneas con producto, cantidad, precio congelado y subtotal — datos que load() trajo en exactamente 3 consultas: el pedido, su cliente, y detalles+productos juntos. Presupuesto de consultas bajo control.
Puntos clave
- Route::resource + only(): lectura primero, resto cuando toque.
- Type-hint Pedido $pedido = búsqueda + 404 automática (binding).
- getRouteKeyName() adapta el binding a PKs no estándar.
- with('cliente') antes de iterar: N+1 jamás (preventLazyLoading vigila).
- x-estado, moneda(), route(): piezas previas componiendo UI real.
21 · Guardar un pedido: transacciones
Intermedio ~18 minLa joya del dominio, tercera vez en la saga: registrar un pedido es crear la cabecera, N líneas con precio congelado y descontar stock — TODO o NADA. En el MVC lo hicimos con beginTransaction/commit/rollBack a mano. Aquí la misma atomicidad vive dentro de una función anónima.
- Validar un formulario de líneas anidadas (items.*.campo).
- Ejecutar DB::transaction() entendiendo rollback automático por excepción.
- Congelar precio al vuelo y descontar stock con lockForUpdate.
- Redirigir con flash y comprobar que el fallo no deja rastros.
El formulario: hasta tres líneas
<form method="POST" action="{{ route('pedidos.store') }}">
@csrf
<select name="cliente_id">
@foreach ($clientes as $c)
<option value="{{ $c->cliente_id }}">{{ $c->nombre }}</option>
@endforeach
</select>
{{-- tres filas opcionales: producto + cantidad --}}
@foreach (range(1, 3) as $fila)
<select name="items[{{ $fila }}][producto_id]"> ... </select>
<input name="items[{{ $fila }}][cantidad]">
@endforeach
<button>Registrar pedido</button>
</form>store(): validar, luego transaccionar
<?php
use App\Models\Pedido;
use App\Models\Producto;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
public function store(Request $request)
{
$validos = $request->validate([
'cliente_id' => ['required', 'exists:clientes,cliente_id'],
'items' => ['required', 'array', 'min:1'],
'items.*.producto_id' => [
'required',
Rule::exists('productos', 'producto_id')->where('activo', true),
],
'items.*.cantidad' => ['required', 'integer', 'min:1'],
]);
$pedido = DB::transaction(function () use ($validos) {
// bloqueo de fila anti-oversell mientras dura la transacción
$ids = collect($validos['items'])->pluck('producto_id');
$productos = Producto::whereIn('producto_id', $ids)
->lockForUpdate()->get()->keyBy('producto_id');
foreach ($validos['items'] as $item) {
$p = $productos[$item['producto_id']];
if ($p->stock < $item['cantidad']) {
// lanza => rollback automático + redirect con error
throw ValidationException::withMessages([
'items' => "Stock insuficiente de {$p->nombre}",
]);
}
}
$pedido = Pedido::create([
'cliente_id' => $validos['cliente_id'],
'estado' => 'REGISTRADO',
'total' => 0,
]);
$total = 0;
foreach ($validos['items'] as $item) {
$p = $productos[$item['producto_id']];
$pedido->detalles()->create([
'producto_id' => $p->producto_id,
'cantidad' => $item['cantidad'],
'precio_unitario' => $p->precio, // CONGELADO aquí y ahora
]);
Producto::where('producto_id', $p->producto_id)
->decrement('stock', $item['cantidad']);
$total += $item['cantidad'] * $p->precio;
}
$pedido->update(['total' => $total]);
return $pedido;
});
return redirect()
->route('pedidos.show', $pedido)
->with('aviso', "Pedido #{$pedido->pedido_id} registrado");
}Las cuatro garantías de la caja
- Toda o nada: cualquier excepción dentro del closure dispara ROLLBACK automático — ni cabecera huérfana ni stock fantasma.
- lockForUpdate(): los productos leídos quedan bloqueados para otros writes hasta el commit — dos vendedores simultáneos no venden el mismo último stock (SELECT ... FOR UPDATE bajo el capó).
- precio_unitario = $p->precio: el histórico congela el valor del instante; mañana cambia el catálogo y este pedido sigue cuadrando.
- redirect + flash: patrón POST-redirect-GET del cap. 13 cerrando el círculo.
La prueba del fallo limpio
Pedir 99 monitores (hay 8) debe fallar SIN dejar cabecera ni detalles:
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:8000/pedidos -d "cliente_id=1"
# desde el navegador (con @csrf), intento sobre-stock muestra:
# «Stock insuficiente de Monitor 24"» + old() repintado
php artisan tinker
Nada creció: la excepción reventó ANTES de crear nada, y aunque hubiera reventado a mitad, el rollback habría borrado los pasos previos. Atomicidad verificada con contadores — el mismo experimento del manual MVC, ahora contra MariaDB real.
Puntos clave
- DB::transaction(closure): excepción = rollback completo automático.
- lockForUpdate() durante la lectura evita oversell concurrente.
- items.*.regla valida arreglos anidados línea por línea.
- precio congelado al crear el detalle; total recalculado al cierre.
- Fallo limpio: contadores intactos prueban la atomicidad.
22 · Editar y borrar: update y destroy
Intermedio ~16 minEl esqueleto de ProductoControlador que generamos en el capítulo 8 estaba vacío a propósito. Hoy le entra sangre: formulario de edición rellenado, update con las mismas reglas que store, y borrado que choca de frente con una decisión que tomamos hace cinco capítulos — la FK restrictOnDelete.
- Rellenar el resource completo de productos con reglas compartidas store/update.
- Prefijar el formulario de edición combinando old() + valores del modelo.
- Simular PUT/DELETE desde HTML con @method.
- Convertir el error de FK en un mensaje útil (y anular en vez de borrar).
Reglas compartidas: una sola fuente
<?php
// App\Http\Controllers\Web\ProductoControlador
private function reglas(): array
{
return [
'nombre' => ['required', 'string', 'max:120'],
'precio' => ['required', 'numeric', 'min:0'],
'stock' => ['required', 'integer', 'min:0'],
];
}
public function store(Request $request)
{
$producto = Producto::create($request->validate($this->reglas()));
return redirect()->route('productos.index')
->with('aviso', "Producto #{$producto->producto_id} creado");
}
public function update(Request $request, Producto $producto)
{
$producto->fill($request->validate($this->reglas()))->save();
return redirect()->route('productos.index')
->with('aviso', "Producto actualizado");
}Método privado + dos llamadas: si mañana el precio exige mínimo 0.50, se cambia UNA vez. fill()+save() respeta el $fillable del capítulo 17 — mass assignment blindado también al editar.
El formulario de edición: old() con plan B
<!-- resources/views/productos/editar.blade.php -->
<form method="POST" action="{{ route('productos.update', $producto) }}">
@csrf
@method('PUT') {{-- HTML no conoce PUT: se simula con hidden -->
<input name="nombre" value="{{ old('nombre', $producto->nombre) }}">
<input name="precio" value="{{ old('precio', $producto->precio) }}">
<input name="stock" value="{{ old('stock', $producto->stock) }}">
<button>Guardar cambios</button>
</form>old('campo', $modelo->campo): si hubo errores vuelve lo tecleado;
si es visita fresca, lo guardado. Segundo argumento = valor por defecto — la pieza
que faltaba del helper del capítulo 12.
Borrar: cuando la base dice NO
<?php
use Illuminate\Database\QueryException;
public function destroy(Producto $producto)
{
try {
$producto->delete();
} catch (QueryException) {
// restrictOnDelete del cap. 16 disparó la violación de FK
return back()->with('error',
"{$producto->nombre} tiene ventas registradas: no se borra");
}
return redirect()->route('productos.index')
->with('aviso', 'Producto eliminado');
}La FK restrictiva convirtió un posible desastre contable (ventas huérfanas) en una
excepción predecible. Nosotros la traducimos a mensaje humano. La regla de negocio
fina: los productos con historial NO se borran — se desactivan
($producto->update(['activo' => false])) y desaparecen del catálogo
pero sobreviven en los detalles históricos.
<form method="POST" action="{{ route('productos.destroy', $producto) }}"
onsubmit="return confirm('¿Eliminar este producto?')">
@csrf
@method('DELETE')
<button>Borrar</button>
</form>Las dos caras del espejo
# producto CON ventas (Teclado, id 1) — mensaje de error
php artisan tinker
CRUD completo de catálogo servido. Los pedidos, recuerda, NO se borran ni editan: se ANULAN cambiando estado — el dominio manda, el framework obedece.
Puntos clave
- reglas() privada compartida por store/update: una sola verdad.
- old('campo', $valor-del-modelo): repintado con memoria y plan B.
- @method('PUT'/'DELETE'): verbos simulados desde formularios HTML.
- QueryException por FK restrict = negocio: mensaje o desactivar.
- fill()+save() mantiene $fillable vigilando también la edición.
23 · Scopes: filtros reutilizables
Intermedio ~14 minCada listado del manual repite la misma pregunta: «¿solo los activos? ¿solo los PAGADO?». El manual Eloquent nos enseñó a encapsularla en scopes; aquí lo formalizamos con el generador de por medio y una mejora que los hace encadenables hasta desde las relaciones.
- Escribir scopes locales con scopeXxx() y usarlos como métodos encadenables.
- Pasar parámetros a un scope (estado, rangos).
- Filtrar condicionalmente con when() según query string.
- Conocer los global scopes — y cuándo NO inventarlos.
Scopes locales: verbos del dominio
<?php
use Illuminate\Database\Eloquent\Builder;
// App\Models\Producto
public function scopeActivos(Builder $query): void
{
$query->where('activo', true);
}
public function scopeConStock(Builder $query): void
{
$query->where('stock', '>', 0);
}
// App\Models\Pedido
public function scopeEstado(Builder $query, string $estado): void
{
$query->where('estado', $estado);
}scopeEstado se convierte en ->estado('PAGADO'): Laravel elimina el prefijo y pasa el Builder como primer argumento. Los parámetros extra llegan después. El resultado es un vocabulario de negocio consultable — el mismo idioma de config('pedidos.estados'), los estados de factory y las reglas de validación.
Encadenar desde relaciones
Mira la diferencia sutil: pedidos() CON paréntesis devuelve el query
builder (acepta scopes, count, where); sin paréntesis devuelve la COLECCIÓN ya cargada.
El scope vive del lado builder — esa distinción evita el 90% de confusiones con Eloquent.
Filtros condicionales: when()
El listado del cap. 20 gana su primer filtro real. Estado llega por query string y SOLO se aplica si existe:
<?php
public function index(Request $request)
{
$estado = $request->query('estado');
if (! in_array($estado, config('pedidos.estados'), true)) {
$estado = null; // valor raro → sin filtro
}
return view('pedidos.lista', [
'pedidos' => Pedido::with('cliente')
->when($estado, fn ($q) => $q->estado($estado))
->orderByDesc('fecha_pedido')
->get(),
'filtro' => $estado,
]);
}->when($condicion, $callback) aplica el closure SOLO si la condición
es verdadera — adiós al if/else duplicando la consulta. La vista añade un select GET
que envía ?estado=PAGADO; withQueryString (capítulo siguiente) mantendrá ese filtro
vivo entre páginas.
Global scopes: gran poder
Un scope GLOBAL se aplica a TODA consulta del modelo, sin llamarlo. Ya usaste uno
sin saberlo: SoftDeletes es exactamente eso — agrega
«WHERE deleted_at IS NULL» a cada lectura. Definirlos a mano es posible
(método bootScoped), pero cada global invisible es una sorpresa futura para tu yo de
mañana («¿por qué no aparece el registro?»). Regla práctica: dominio visible = scope
local explícito; comportamiento estructural maduro (soft deletes, multi-tenant) =
global.
Puntos clave
- scopeXxx(Builder $q) = método encadenable con nombre de negocio.
- Parámetros tras el builder: estado(string), precioEntre(min, max).
- $cliente->pedidos() builder vs ->pedidos colección cargada.
- when() aplica filtros solo cuando existen — sin if duplicado.
- SoftDeletes YA ES un global scope: poder grande, uso medido.
24 · Paginación: paginate() y familia
Intermedio ~15 minEl MVC paginaba a pulmón: COUNT para el total, ceil() para páginas, LIMIT con OFFSET calculado y HTML de enlaces cosido a mano. Laravel reduce TODO eso a un método que devuelve objetos listos tanto para Blade como para JSON — y hoy lo conectamos con los scopes del capítulo anterior.
- Reemplazar el listado artesanal por paginate(10) + links().
- Elegir entre paginate / simplePaginate / cursorPaginate con criterio.
- Mantener filtros vivos entre páginas con withQueryString().
- Saber qué llega en el JSON del paginador (puente a la Parte V).
De paginar() a paginate()
<?php
// ANTES (esencia del paginar() artesanal):
$total = count($todos);
$paginas = (int) ceil($total / 10);
$offset = ($pagina - 1) * 10;
$filas = array_slice($todos, $offset, 10);
// ... más el HTML de enlaces, la validación de página, etc.
// AHORA:
$pedidos = Pedido::with('cliente')
->when($estado, fn ($q) => $q->estado($estado))
->orderByDesc('fecha_pedido')
->paginate(10)
->withQueryString(); // ?estado=... sobrevive al cambio de páginaDato verificado contra docs oficiales de 13.x: sin argumento, paginate usa 15 por página (el default histórico — los blogs que anunciaban 25 se equivocaron). Regla del manual: SIEMPRE explícito. El objeto devuelto es iterable como una colección PERO trae total, página actual y URLs calculadas.
Los tres paginadores
| Método | Consultas | Úsalo cuando… |
|---|---|---|
| paginate(10) | COUNT + SELECT | necesitas «1–10 de 30» y números de página |
| simplePaginate(10) | solo SELECT | basta «Anterior/Siguiente» — ahorra el COUNT |
| cursorPaginate(10) | WHERE > cursor | volumen enorme o feeds infinitos |
-- offset (clásico): escanea lo saltado
SELECT * FROM pedidos ORDER BY pedido_id ASC LIMIT 10 OFFSET 10;
-- cursor (moderno): arranca directo en el punto marcado
SELECT * FROM pedidos WHERE pedido_id > 10 ORDER BY pedido_id ASC LIMIT 10;El cursor exige ORDER BY sobre columnas únicas indexadas y solo ofrece anterior/siguiente — a cambio no salta ni duplica filas cuando la tabla respira (inserts/borres durante tu lectura). Para el panel admin: paginate clásico.
links(): los botones gratis
<p>
Mostrando {{ $pedidos->firstItem() }}–{{ $pedidos->lastItem() }}
de {{ $pedidos->total() }}
</p>
@foreach ($pedidos as $p)
{{-- filas idénticas a siempre --}}
@endforeach
{{ $pedidos->onEachSide(2)->links() }}Por defecto links() pinta HTML estilo Tailwind. Como nuestro proyecto viste Bootstrap, una línea en AppServiceProvider cambia el vestuario:
<?php
use Illuminate\Pagination\Paginator;
public function boot(): void
{
Model::preventLazyLoading(! $this->app->isProduction());
Paginator::useBootstrapFive(); // vistas pagination bootstrap-5
}(¿Quieres HTML propio? vendor:publish --tag=laravel-pagination exporta las plantillas a resources/views/vendor/pagination.) La demo necesita volumen — 26 productos extra, DETERMINISTAS gracias al índice del sequence:
\App\Models\Producto::factory()->count(26)->sequence(
fn (Sequence $seq) => ['nombre' => "Producto demo {$seq->index}", 'precio' => 9.99]
)->create()
php artisan migrate:fresh --seed
curl -s "http://127.0.0.1:8000/productos?page=2" | grep -o 'Producto demo 1[45]'
Página 2 = ítems 11–20 de 30. Los enlaces generados ya traen page=N y —gracias a withQueryString— conservarían cualquier filtro activo.
Y en JSON…
Devolver el paginator desde una ruta produce automáticamente:
{
"total": 30,
"per_page": 10,
"current_page": 2,
"last_page": 3,
"from": 11,
"to": 20,
"data": [ ... ]
}Esa forma data+meta es EL contrato estándar de APIs REST paginadas. En la Parte V la envolveremos con API Resources — pero el esqueleto ya lo estás viendo.
Puntos clave
- paginate(N) explícito siempre (default histórico: 15).
- withQueryString(): filtros del cap. 23 sobreviven entre páginas.
- simple ahorra COUNT; cursor escala y exige ORDER único.
- useBootstrapFive() en AppServiceProvider viste los links.
- JSON del paginator: data + meta — base del API de la Parte V.
25 · Dashboard: la vista que lo junta todo
Intermedio ~15 minCierre de la Parte IV. El dashboard del MVC juntaba conteos y últimos pedidos con SQL a mano. Esta versión se construye casi sin escribir consultas: cada número sale de un scope, un agregado o una relación ya definidos — y el controlador entero cabe en UN método gracias al single-action del cap. 8.
- Crear un controlador invocable con --invokable y enrutarlo.
- Calcular métricas con count()/sum() sobre scopes (presupuesto fijo).
- Usar latest() para los últimos registros sin escribir orderBy.
- Montar la vista con tarjetas de resumen + tabla reutilizando componentes.
Un controlador, un método
<?php
// App\Http\Controllers\PanelControlador
namespace App\Http\Controllers;
use App\Models\Pedido;
use App\Models\Producto;
use Illuminate\Contracts\View\View;
class PanelControlador extends Controller
{
public function __invoke(): View
{
return view('panel', [
'stats' => [
'total' => Pedido::count(),
'registrados' => Pedido::estado('REGISTRADO')->count(),
'pagados' => Pedido::estado('PAGADO')->count(),
'anulados' => Pedido::estado('ANULADO')->count(),
'ingresos' => moneda(Pedido::estado('PAGADO')->sum('total')),
'stock_bajo' => Producto::activos()
->where('stock', '<=', 5)->count(),
],
'ultimos' => Pedido::with('cliente')
->latest('fecha_pedido')
->limit(5)
->get(),
]);
}
}<?php
// routes/web.php
use App\Http\Controllers\PanelControlador;
Route::get('/panel', PanelControlador::class)->name('panel');Sin método declarado en la ruta: __invoke ES la acción. Cada métrica es UNA consulta agregada — 8 queries en total, FIJAS sin importar si hay 30 o 3 millones de filas. Presupuesto cerrado y auditable: así se piensa un panel serio.
latest() y el detalle de los scopes
latest('fecha_pedido') es azúcar para orderByDesc — legible de inmediato.
Nota cómo las métricas leen como el negocio habla:
Pedido::estado('PAGADO')->sum('total'): «suma de totales de pedidos
pagados». El capítulo 23 convirtió SQL en vocabulario; hoy el dashboard lo aprovecha
sin definir nada nuevo.
La vista: tarjetas + tabla
<!-- resources/views/panel.blade.php -->
@extends('layouts.app')
@section('contenido')
<h1>Panel de Pedidos</h1>
<div class="tarjetas">
<div class="tarjeta"><b>{{ $stats['total'] }}</b> pedidos</div>
<div class="tarjeta"><b>{{ $stats['pagados'] }}</b> pagados</div>
<div class="tarjeta"><b>{{ $stats['ingresos'] }}</b> ingresos</div>
<div class="tarjeta alerta"><b>{{ $stats['stock_bajo'] }}</b> stock ≤ 5</div>
</div>
<h2>Últimos pedidos</h2>
<table>
@foreach ($ultimos as $p)
<tr>
<td><a href="{{ route('pedidos.show', $p) }}">#{{ $p->pedido_id }}</a></td>
<td>{{ $p->cliente->nombre }}</td>
<td><x-estado :estado="$p->estado" /></td>
<td>{{ moneda($p->total) }}</td>
</tr>
@endforeach
</table>
<a href="{{ route('pedidos.create') }}">+ Registrar pedido</a>
@endsection# aquí solo importa el DATO. Verificación rápida:
curl -s http://127.0.0.1:8000/panel | grep -oE '<b>[^<]*</b>'
Tres pedidos sembrados, uno PAGADO dejando S/ 305.30… y 26 productos en alerta de stock. ¿Por qué tanto? Los «demo» del capítulo 24 no definieron stock y usaron el default 0 — que cuenta como crítico bajo la regla ≤5. El dato no miente: expone el supuesto que olvidamos. Un número incómodo en el panel es el panel HACIENDO su trabajo.
Cache::remember('panel.stats', ...), cap. 38) y a una ruta de
API para un frontend aparte (Parte V). La lógica YA está encapsulada: moverla costará
minutos.Balance de la Parte IV: CRUD web completo sobre dominio transaccional — listado filtrable y paginado, detalle con 3 queries, alta atómica con stock bloqueado, edición/borrado con FKs hablando, y panel que resume sin sudar. La Parte V expone TODO este músculo por HTTP puro: API REST.
Puntos clave
- --invokable: un controlador = una acción = __invoke().
- Métricas = count/sum sobre scopes: 8 queries fijas, cero sorpresas.
- latest(col) = orderByDesc legible; limit antes de get.
- El dashboard existe para contradecir supuestos — déjalo honesto.
- Lógica encapsulada hoy = caché/API baratas mañana (caps. 38/26+).
26 · La API: rutas sin estado
Intermedio ~15 minAbre la Parte V. En el MVC, devolver JSON era bricolaje: header('Content-Type: application/json'), echo json_encode(), exit. Y cada «cliente» compartía sesión, cookies y CSRF con las vistas. Una API de verdad vive en otro mundo: sin estado, sin sesiones, hablando HTTP puro. Laravel le dedica un archivo propio.
- Activar el mundo API con install:api y entender qué instala.
- Entender «sin estado»: qué desaparece (sesión, CSRF, cookies).
- Usar apiResource y el prefijo /api automático.
- Hablarle a Laravel con Accept: application/json (errores incluidos).
install:api: un comando, tres piezas
php artisan migrate # trae su propia migración pendiente
El comando hace exactamente tres cosas: crea routes/api.php, instala
Sanctum (nuestra materia del cap. 28) y publica la migración de
personal_access_tokens. Nada más que revisar:
Una ruta de ejemplo, protegida con auth:sanctum, lista para borrar o usar.
Sin estado: lo que el grupo «api» NO carga
| Grupo web (caps. previos) | Grupo api (desde hoy) |
|---|---|
| Sesiones y flash | nada de sesión — cada petición se basta sola |
| CSRF (@csrf, 419) | innecesario: no hay cookies que falsificar |
| Redirect + old() | errores como JSON 422 |
| Blade, componentes | solo datos: JSON entra y sale |
| — | throttle: límite de peticiones por minuto |
Esa última fila importa: el grupo api incluye limitación de ritmo integrada (60 peticiones por minuto por usuario o IP, configurable con RateLimiter::for('api') en AppServiceProvider). El excedente recibe un 429. Tu primera defensa contra abusos, gratis.
El prefijo /api y el controlador --api
<?php
// routes/api.php
use App\Http\Controllers\Api\ProductoControlador;
Route::apiResource('productos', ProductoControlador::class)
->only(['index', 'show']);Dos detalles finos. Primero: todo lo que declaremos aquí cuelga de
/api/ automáticamente — el prefijo vive en bootstrap/app.php
(->withRouting(api: ..., apiPrefix: 'api')) y se puede cambiar una vez,
no ruta por ruta. Segundo: apiResource registra solo las 5 acciones REST
— sin create ni edit, porque una API no sirve formularios HTML.
Hablar JSON: el header Accept
curl -i -X POST http://127.0.0.1:8000/api/productos \
-d nombre=X -d precio=-5
# CON el header: errores en JSON, status correctos
curl -i -X POST http://127.0.0.1:8000/api/productos \
-H "Accept: application/json" -d nombre=X -d precio=-5
Mismo validate() del cap. 12, distinto idioma: si el cliente pide JSON (header Accept o X-Requested-With), Laravel cambia redirect+flash por un 422 con cuerpo estructurado message/errors. Un solo código de validación, dos audiencias. El 404 de binding también habla JSON bajo ese header.
Primera lectura pública
<?php
// App\Http\Controllers\Api\ProductoControlador
public function index()
{
return Producto::orderBy('nombre')->get();
}
public function show(Producto $producto)
{
return $producto;
}Funciona… pero mira qué está exponiendo: la PK interna con nombre legacy (producto_id), marcas de tiempo internas, y mañana expondría lo que sea que agreguemos al modelo. Devolver el modelo crudo es dejar la caja registradora abierta. El capítulo siguiente pone el portero.
Puntos clave
- install:api = routes/api.php + Sanctum + migración de tokens.
- Sin estado: adiós sesión/CSRF/cookies; hola throttle y JSON.
- Prefijo /api centralizado en bootstrap/app.php (apiPrefix).
- apiResource: las 5 acciones REST, sin formularios.
- Accept: application/json transforma validación y 404 en JSON.
- Modelo crudo en la respuesta = contrato frágil: falta la capa.
27 · API Resources: la forma del JSON
Intermedio ~16 minEl capítulo anterior terminó con el modelo crudo asomando campos internos. Un contrato de API serio decide QUÉ campos salen, CON qué nombres y CUÁNDO aparecen las relaciones. Esa decisión vive en una clase: el API Resource.
- Generar un resource con make:resource y escribir su toArray().
- Renombrar campos (PK legacy incluida) y derivar valores.
- Incluir relaciones solo si están cargadas: whenLoaded() anti-N+1.
- Paginar resources y conocer la envoltura data/links/meta.
Un transformador por modelo
# crea app/Http/Resources/ProductoResource.php
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ProductoResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->producto_id, // PK legacy renombrada
'nombre' => $this->nombre,
'precio' => $this->precio, // "129.90" gracias al cast
'stock' => $this->stock,
'activo' => $this->activo,
];
}
}$this DENTRO del resource ES el modelo: Laravel hace proxy de propiedades y métodos. Lo que no listas, no viaja — created_at, updated_at y cualquier campo futuro quedan fuera del contrato sin tocar el controlador. Y puedes DERIVAR:
<?php
// App\Http\Resources\PedidoDetalleResource (extracto)
public function toArray(Request $request): array
{
return [
'producto_id' => $this->producto_id,
'cantidad' => $this->cantidad,
// precio congelado en su día (cap. 21):
'precio_unitario' => $this->precio_unitario,
'subtotal' => bcmul($this->cantidad, $this->precio_unitario, 2),
];
}Relaciones bajo llave: whenLoaded()
<?php
// App\Http\Resources\PedidoResource (extracto)
public function toArray(Request $request): array
{
return [
'id' => $this->pedido_id,
'estado' => $this->estado,
'total' => $this->total,
'cliente' => new ClienteResource($this->whenLoaded('cliente')),
'detalles' => PedidoDetalleResource::collection(
$this->whenLoaded('detalles')),
];
}whenLoaded('cliente'): la clave SOLO aparece si la relación fue cargada
con with()/load(). El controlador decide cuánto incluir; el resource nunca dispara
consultas por sorpresa. Es la versión HTTP de la disciplina anti-N+1 del cap. 19 —
show() carga las 3 consultas presupuestadas y el JSON sale completo; index() no carga
detalles y el JSON sale liviano.
Colecciones y paginación
<?php
// Api\ProductoControlador::index, ahora con contrato
public function index()
{
return ProductoResource::collection(
Producto::where('activo', true)
->orderBy('nombre')
->paginate(10)
);
}
// atajo oficial para UN modelo:
return Producto::find(1)->toResource();| python3 -m json.tool | head -n 24
Aquí está TODO lo que prometió el cap. 24: el paginator dentro del resource produce la tríada canónica data/links/meta — los ítems en «data», navegación lista para consumir en «links», estadísticas en «meta». Cualquier frontend moderno reconoce esa forma de inmediato.
Mención: JSON:API nativo
make:resource PostResource --json-api genera
una clase con propiedades $attributes y $relationships, respeta ?include= y ?fields=
del estándar y responde con Content-Type application/vnd.api+json. Potente cuando tu
cliente EXIGE ese formato; para nuestra API propia, el Resource clásico es más
directo.También existen los atributos #[UseResource]/#[UseResourceCollection] para fijar en el MODELO qué clase lo transforma — útil cuando el nombre no sigue la convención Producto→ProductoResource.
Puntos clave
- toArray() = contrato: qué sale, con qué nombre, nada más.
- $this es el modelo; los campos derivados se calculan ahí.
- whenLoaded(): relaciones visibles solo si el controlador las trajo.
- paginate + collection = data/links/meta estándar.
- toResource() y --json-api: atajos oficiales de 13.x.
28 · Sanctum: llaves para tu API
Intermedio ~16 minNuestra API lee libremente, pero escribir (crear pedidos, anular) no puede ser anónimo. Para web usamos sesión+cookie; una API sin estado necesita otra cosa: llaves explícitas que viajen en cada petición. Sanctum —que install:api ya dejó instalado— es exactamente eso, sin la ceremonia de OAuth.
- Emitir tokens personales y entender dónde viven.
- Proteger rutas de escritura con auth:sanctum.
- Consumir con curl -H "Authorization: Bearer ...".
- Limitar qué puede hacer cada llave con abilities; revocarlas.
La idea: personal access tokens
Mismo modelo que GitHub: el usuario genera una «llave» desde su cuenta, la copia UNA vez y la usa en sus scripts. En la base de datos NO vive la llave sino su hash SHA-256 — si te roban la tabla, no te roban las llaves. Cada petición trae la llave en texto plano en el header Authorization y Sanctum la verifica contra esos hashes.
<?php
// App\Models\User — ya viene de fábrica:
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
}Nota de honestidad: nuestro dominio Pedidos nunca necesitó users (los clientes viven en su propia tabla), pero el esqueleto trae users/User desde el día uno esperando este momento. Hoy la estrenamos como dueña de las llaves:
createToken('nombre') devuelve un NewAccessToken; la cadena visible se
muestra UNA sola vez (el usuario debe copiarla al instante). El prefijo 3| es el id del
registro en personal_access_tokens.
Proteger la escritura
<?php
// routes/api.php
Route::apiResource('productos', ProductoControlador::class)
->only(['index', 'show']); // lectura pública
Route::middleware('auth:sanctum')->group(function () {
Route::post('/productos', [ProductoControlador::class, 'store']);
Route::put('/productos/{producto}', [ProductoControlador::class, 'update']);
Route::get('/pedidos', [PedidoControlador::class, 'index']);
Route::post('/pedidos', [PedidoControlador::class, 'store']);
});# sin llave: rechazo 401 (ni llega a validar)
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
localhost:8000/api/productos -H "$A" \
-H "Content-Type: application/json" \
-d '{"nombre":"X","precio":"1","stock":1}'
# con llave: crea (store espeja el cap. 22)
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
localhost:8000/api/productos -H "$A" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer 3|9fZc..." \
-d '{"nombre":"Cable HDMI","precio":"15.00","stock":50}'
auth:sanctum revisa el header contra la tabla; sin llave válida responde 401 Unauthenticated ANTES de tocar tu controlador. La misma ruta, dos destinos, decididos por un header.
Abilities: qué puede hacer cada llave
<?php
// emitir con permisos acotados:
$token = $user->createToken('reportes-solo-lectura',
['pedidos:index']);
$token->plainTextToken;
// en un middleware o controlador:
if ($request->user()->tokenCan('pedidos:index')) { /* ... */ }Las abilities son etiquetas de texto que TÚ defines — el rol de OAuth scopes sin su baile de redirecciones. Verificación dura por ruta usando alias oficiales en bootstrap/app.php:
<?php
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'abilities' => CheckAbilities::class,
'ability' => CheckForAnyAbility::class,
]);
})
// routes/api.php:
Route::middleware(['auth:sanctum', 'abilities:pedidos:index'])
->get('/pedidos', ...); // exige ESA abilityDiferencia clave: abilities:a,b exige TODAS; ability:a,b
basta con UNA. Y para revocar: $user->->tokens()->delete() borra todas,
$request->user()->currentAccessToken()->delete() solo la llave en uso —
el clásico botón «cerrar esta sesión del móvil».
Puntos clave
- Llave estilo GitHub: hash SHA-256 guardado, Bearer en el header.
- HasApiTokens viene de fábrica en User; createToken emite.
- auth:sanctum = 401 antes de llegar a tu código.
- abilities:/ability: limitan cada llave a lo suyo.
- Revocación quirúrgica: currentAccessToken() o todas.
29 · Endpoints Pedidos: el CRUD por HTTP
Intermedio ~17 minTodo el músculo construido en la Parte IV se expone hoy por HTTP puro: scopes, paginación, resources, tokens y la transacción del cap. 21 convergen en un controlador API que cabe en una pantalla. El dominio manda las diferencias: aquí NO hay delete ni edición libre.
- Armar Api\PedidoControlador reutilizando scopes y presupuesto de queries.
- Crear pedidos vía POST JSON devolviendo 201 con contrato limpio.
- Cambiar SOLO estados válidos (transición, no edición libre).
- Ver los códigos HTTP trabajando: 201, 200, 401, 405, 422.
Las rutas y sus silencios
<?php
// routes/api.php
Route::get('/pedidos', [PedidoControlador::class, 'index']);
Route::get('/pedidos/{pedido}', [PedidoControlador::class, 'show'])
->middleware('auth:sanctum');
Route::middleware('auth:sanctum')->group(function () {
Route::post('/pedidos', [PedidoControlador::class, 'store']);
Route::patch('/pedidos/{pedido}', [PedidoControlador::class, 'update']);
});Fíjate en lo que FALTA: DELETE. Los pedidos no se borran — se anulan (regla del
cap. 16). Si alguien insiste con DELETE /api/pedidos/1, Laravel responde solo:
405 Method Not Allowed, porque ninguna ruta lo atiende. La ausencia
también es documentación.
Listar y mostrar: el presupuesto intacto
<?php
// App\Http\Controllers\Api\PedidoControlador
public function index(Request $request)
{
$estado = $request->query('estado');
if (! in_array($estado, config('pedidos.estados'), true)) {
$estado = null;
}
return PedidoResource::collection(
Pedido::with('cliente') // anti-N+1
->when($estado, fn ($q) => $q->estado($estado))
->orderByDesc('fecha_pedido')
->paginate(10)->withQueryString()
);
}
public function show(Pedido $pedido) // binding por pedido_id (cap. 20)
{
return new PedidoResource(
$pedido->load(['cliente', 'detalles']) // +2 sobre el find
);
}Mismas piezas de siempre: scope estado(), with() contado, paginate(10) — tres consultas fijas (COUNT + página + clientes). El binding por getRouteKeyName trabaja igual que en web: {pedido} resuelve por pedido_id y da 404 JSON si falta. Y nota qué NO carga show(): producto de cada detalle — el resource no lo expone, así que la consulta se ahorra. El contrato define el presupuesto.
POST: la transacción ahora devuelve 201
<?php
public function store(Request $request)
{
$datos = $request->validate([
'cliente_id' => ['required', 'exists:clientes,cliente_id'],
'items' => ['required', 'array', 'min:1'],
'items.*.producto_id' => ['required',
Rule::exists('productos', 'producto_id')
->where('activo', true)],
'items.*.cantidad' => ['required', 'integer', 'min:1'],
]);
$pedido = DB::transaction(function () use ($datos) {
$pedido = Pedido::create([
'cliente_id' => $datos['cliente_id'],
'estado' => 'REGISTRADO', // todo pedido nace aquí
'total' => 0,
]);
foreach ($datos['items'] as $item) {
$producto = Producto::where('activo', true)
->lockForUpdate() // anti-oversell
->findOrFail($item['producto_id']);
if ($producto->stock < $item['cantidad']) {
throw ValidationException::withMessages([
"items.{$item['producto_id']}" => ['Stock insuficiente'],
]);
}
$pedido->detalles()->create([
'producto_id' => $producto->producto_id,
'cantidad' => $item['cantidad'],
'precio_unitario' => $producto->precio, // CONGELADO
]);
$producto->decrement('stock', $item['cantidad']);
}
$pedido->update([
'total' => $pedido->detalles->sum(
fn ($d) => bcmul($d->cantidad, $d->precio_unitario, 2)),
]);
return $pedido;
});
return response()->json(new PedidoResource($pedido), 201);
}Es la MISMA caja del cap. 21 — candado, precio congelado, stock decrementado,
fallo limpio — con una diferencia de salida: en vez de redirect+flash, un
201 Created con el contrato JSON. Ejercicio propuesto: extraer ese closure
a una clase CrearPedido compartida por Web\PedidoControlador y este controlador — el
negocio debe tener UNA implementación y dos puertas.
PATCH: transiciones, no ediciones
<?php
public function update(Request $request, Pedido $pedido)
{
$validos = $request->validate([
'estado' => ['required', Rule::in(config('pedidos.estados'))],
]);
if ($pedido->estado !== 'REGISTRADO') {
throw ValidationException::withMessages([
'estado' => ["El pedido está {$pedido->estado}: terminal"],
]);
}
$pedido->update($validos);
return new PedidoResource($pedido);
}Solo un REGISTRADO puede moverse a PAGADO o ANULADO; los otros dos estados son terminales. Ni montos, ni fechas, ni cliente: un pedido histórico no se edita.
La sesión completa en curl
A="Accept: application/json"
H="Authorization: Bearer $TOKEN"
# 1) crear pedido para Carmen (id 3) con 1 Mouse (id 2)
curl -s -X POST localhost:8000/api/pedidos -H "$A" -H "$H" \
-H "Content-Type: application/json" \
-d '{"cliente_id":3,"items":[{"producto_id":2,"cantidad":1}]}'
# 2) marcar PAGADO el pedido #2 de Luis
curl -s -X PATCH localhost:8000/api/pedidos/2 -H "$A" -H "$H" \
-H "Content-Type: application/json" -d '{"estado":"PAGADO"}'
# 3) listar solo PAGADO (ya público, sin token)
curl -s "localhost:8000/api/pedidos?estado=PAGADO" -H "$A"
Tres verbos, tres intenciones, cero HTML. Y si algo falla, los errores ya conocidos llegan en su idioma: 401 sin llave (cap. 28), 422 por estado terminal o ítem inválido, 405 ante métodos no previstos.
Puntos clave
- Ausencia de ruta = 405: el dominio también se documenta borrando.
- Mismo scope+with+paginate: el listado sigue costando 3 queries.
- POST exitoso = 201; la transacción del cap. 21 intacta.
- PATCH solo mueve REGISTRADO→PAGADO/ANULADO; resto terminal.
- Negocio único, dos puertas: web (redirect) y API (201/JSON).
30 · Consumir la API desde JavaScript
Intermedio ~15 minCierre de la Parte V. En js_01 ya dominabas fetch; hoy lo apuntas a TU servidor: la misma API que probamos con curl, consumida por una página real. Verás que todo lo que hicimos —contratos limpios, data/meta, códigos HTTP correctos— existe para que ESTE capítulo sea aburrido.
- Consumir /api/productos con fetch y pintar resultados en el DOM.
- Navegar la paginación leyendo meta.links (cap. 27).
- Manejar errores por status: 401 con token vencido, 422 de validación.
- Saber cuándo aparece CORS y cuándo NO (mismo origen).
Una página mínima contra tu propia API
<!-- resources/views/api-demo.blade.php -->
<div>
<button id="btn-cargar">Cargar productos</button>
<button id="btn-siguiente">Siguiente página</button>
<p id="meta"></p>
<ul id="lista"></ul>
<p id="error" hidden></p>
</div>
@push('scripts')
<script>
let urlActual = '/api/productos';
async function cargar(url) {
const respuesta = await fetch(url, {
headers: { 'Accept': 'application/json' },
});
if (! respuesta.ok) {
mostrarError(`La API respondió ${respuesta.status}`);
return;
}
const cuerpo = await respuesta.json();
urlActual = cuerpo.meta.path + '?page=' + cuerpo.meta.current_page;
document.querySelector('#meta').textContent =
`${cuerpo.meta.from}–${cuerpo.meta.to} de ${cuerpo.meta.total}`;
const lista = document.querySelector('#lista');
lista.innerHTML = '';
for (const p of cuerpo.data) {
const li = document.createElement('li');
li.textContent = `${p.nombre} — S/ ${p.precio}`;
lista.append(li);
}
}
document.querySelector('#btn-cargar').onclick = () =>
cargar('/api/productos?page=1');
document.querySelector('#btn-siguiente').onclick = () =>
cargar(`/api/productos?page=${pagina + 1}`);
</script>
@endpushTres decisiones que ya conoces por dentro: el header Accept que aprendiste en el
cap. 26 (sin él, un error devolvería HTML), el chequeo de respuesta.ok ANTES de leer
JSON, y el contrato del cap. 27 consumido tal cual — cuerpo.data para pintar,
cuerpo.meta para navegar. Detalle fino: los nombres se insertan con
textContent, jamás con innerHTML — la lección XSS de js_01 que el
próximo capítulo formalizará. El frontend no necesita saber NADA de MariaDB
ni Eloquent: solo del contrato.
Escribir con llave: POST autenticado
// crear pedido SOLO si el usuario pegó su token de Sanctum:
const token = document.querySelector('#token').value.trim();
const r = await fetch('/api/pedidos', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
cliente_id: 3,
items: [{ producto_id: 2, cantidad: 1 }],
}),
});
if (r.status === 401) {
mostrarError('Tu llave no sirve o expiró');
} else if (r.status === 422) {
const { errors } = await r.json();
mostrarError(Object.values(errors).flat().join(' '));
} else if (r.ok) {
const { data } = await r.json();
alert(`Pedido ${data.id} creado por S/ ${data.total}`); // 45.50
}Los tres caminos que ensayaste con curl en los caps. 28–29, ahora en código de navegador: 401 sin/despilfarrada llave, 422 con errors legibles (¡la misma forma del cap. 12!), 2xx feliz. Un mismo vocabulario HTTP para curl, fetch y Pest.
¿Y CORS?
php artisan config:publish cors y lista tus orígenes permitidos.curl -s -H "Accept: application/json" \
"http://127.0.0.1:8000/api/productos?page=1" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print(d['meta']['total'], len(d['data']))"
31 productos tras el Cable HDMI del cap. 28, 10 por página: exactamente lo que la vista pinta. La Parte V queda servida: API con contratos, llaves y ahora un cliente real. La Parte VI vuelve al mundo web — sesiones, permisos y blindaje.
Puntos clave
- Mismo origen = sin CORS; otro puerto/dominio → config/cors.php.
- Accept + respuesta.ok antes de json(): disciplina cap. 26.
- meta.links/meta.total convierten la paginación en botones.
- 401/422/2xx manejados por status, no por adivinar texto.
- El contrato es TODO lo que el frontend conoce del backend.
31 · Login artesanal: sesión y Hash
Intermedio ~17 minEl MVC armó login a pulmón: $_SESSION['user_id'], password_verify(), header Location. Laravel trae la misma arquitectura con nombres propios — guard session, provider Eloquent — y tres métodos que reemplazan tu plomería entera. Hoy los usamos a mano, sin starter kits, para entender EXACTAMENTE qué automatizan.
- Login con Auth::attempt() y por qué NO se hashea la contraseña entrante.
- Regenerar sesión al entrar (anti-fixation) e invalidar al salir.
- Proteger el /panel con middleware auth + redirect()->intended().
- Hash::make/check y el «remember me» de fábrica.
El trio clásico en versión framework
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class SesionControlador extends Controller
{
public function crear()
{
return view('sesion.ingresar'); // formulario email+password
}
public function guardar(Request $request)
{
$credenciales = $request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
if (! Auth::attempt($credenciales)) {
return back()
->withErrors(['email' => 'Credenciales incorrectas'])
->onlyInput('email');
}
$request->session()->regenerate(); // id nuevo: anti-fixation
return redirect()->intended(route('panel'));
}
public function destruir(Request $request)
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/')->with('aviso', 'Sesión cerrada');
}
}attempt() busca al usuario por email y compara contraseñas INTERNAMENTE — jamás hagas Hash::check manual ni hashee lo que llega del formulario: el framework ya lo compara contra el hash almacenado. Si entra, regenerate() cambia el id de sesión (mismo remedio del fixation attack que el MVC parcheaba a mano). Al salir, la danza de tres pasos oficial: logout + invalidate + regenerateToken (CSRF fresco).
Rutas y protección del panel
<?php
// routes/web.php
use App\Http\Controllers\Auth\SesionControlador;
Route::middleware('guest')->group(function () {
Route::get('/ingresar', [SesionControlador::class, 'crear'])
->name('login'); // nombre CANÓNICO: auth lo busca
Route::post('/ingresar', [SesionControlador::class, 'guardar']);
});
Route::post('/salir', [SesionControlador::class, 'destruir'])
->middleware('auth')->name('salir');
Route::get('/panel', PanelControlador::class)
->middleware('auth')
->name('panel');Dos detalles con premio. El alias guest evita que un usuario logueado vea el
formulario. Y el middleware auth redirige a invitados hacia la ruta llamada
login — por eso ese name no es decorativo. La ruta destino es configurable
(redirectGuestsTo en bootstrap/app.php), pero la convención basta aquí. El dashboard
del cap. 25 acaba de ganar su candado sin tocar una línea de su controlador.
intended(): recordar adónde ibas
Cuando auth intercepta a alguien que iba al panel, guarda esa URL en la sesión.
Tras el login exitoso, redirect()->intended(route('panel')) lo devuelve a
SU destino original (panel u otra protegida que intentara abrir); si no hay destino
guardado, cae al argumento por defecto. El MVC hacía esto con $_SESSION['volver_a']
artesanal — mismo mecanismo, menos código propio.
Registro mínimo y Hash::make
<?php
// dentro de SesionControlador o uno propio de registro:
public function registrar(Request $request)
{
$datos = $request->validate([
'name' => ['required', 'string', 'max:80'],
'email' => ['required', 'email', 'unique:users,email'],
'password' => ['required', 'confirmed', Password::min(8)],
]);
$usuario = User::create([
...$datos,
'password' => Hash::make($datos['password']),
]);
Auth::login($usuario); // entra directo tras registrarse
return redirect()->route('panel');
}$2y$12$ = bcrypt con cost 12: el default de config/hashing.php. Hash::make genera salt nuevo CADA vez — dos llamadas con la misma clave producen hashes distintos, y aun así check() verifica. Por eso NUNCA compares hashes con ===.
Remember me: gratis
Checkbox en el formulario + segundo argumento:
<?php
Auth::attempt($credenciales, $request->boolean('recordar'));Laravel guarda un token en users.remember_token (columna que la migración de fábrica ya creó) y una cookie de larga vida; sesiones expiradas se restauran solas. Cero lógica tuya.
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://127.0.0.1:8000/panel
Invitado pidiendo el panel: 302 hacia /ingresar. Tras login válido (en navegador), el mismo GET devuelve 200 con tus tarjetas. Candado verificado.
Puntos clave
- attempt() valida password internamente: no lo hashees tú.
- regenerate() al entrar, invalidate()+regenerateToken() al salir.
- auth busca la ruta name('login'): convención operativa.
- intended() devuelve al destino interrumpido, con fallback.
- bcrypt salt-per-hash: comparar SIEMPRE con Hash::check.
32 · Policies y Gates: quién puede qué
Intermedio ~16 minAutenticado no es sinónimo de autorizado. Hoy Percy entra al panel, pero ¿debería poder anular cualquier pedido? ¿Crear productos? La respuesta vive en dos piezas: gates (acciones sueltas, tipo rutas) y policies (lógica por modelo, tipo controladores). El MVC repartía estos if() por todos lados; Laravel los centraliza.
- Definir un gate en AppServiceProvider y usarlo como middleware can:.
- Generar PedidoPolicy con --model y entender el autodescubrimiento.
- Autorizar desde controlador ($this->authorize), Blade (@can) y API (403).
- Refactor honesto: la transición inválida del cap. 29 ahora es 403.
Gate: una acción sin modelo dueño
<?php
// App\Providers\AppServiceProvider
use App\Models\User;
use Illuminate\Support\Facades\Gate;
public function boot(): void
{
Model::preventLazyLoading(! $this->app->isProduction());
Paginator::useBootstrapFive();
Gate::define('gestionar-productos', function (User $user) {
return $user->es_admin;
});
}Necesitamos esa columna — una alteración mínima con lo aprendido en el cap. 16:
<?php
Schema::table('users', function (Blueprint $table) {
$table->boolean('es_admin')->default(false)->after('password');
});
// down: $table->dropColumn('es_admin');php artisan tinker
> App\Models\User::where('email', 'percy@pedidos.pe')->update(['es_admin' => true])
= 1
Percy queda como único administrador. El gate se consume como middleware en las rutas de escritura del catálogo:
<?php
// routes/web.php y routes/api.php (escritura de productos):
Route::post('/productos', [ProductoControlador::class, 'store'])
->middleware('can:gestionar-productos');
// API igual, apilada con auth:sanctumPolicy: el contrato del pedido
# crea app/Policies/PedidoPolicy.php — autodescubierta por convención
# (Modelo → ModeloPolicy en app/Policies)
<?php
namespace App\Policies;
use App\Models\Pedido;
use App\Models\User;
class PedidoPolicy
{
public function update(User $user, Pedido $pedido): bool
{
// solo un REGISTRADO admite transición (regla del cap. 29)
return $pedido->estado === 'REGISTRADO';
}
public function delete(User $user, Pedido $pedido): bool
{
return false; // los pedidos NO se borran. Jamás.
}
}Mira qué pasó: la regla de ESTADO que vivía en el controlador migró a la capa donde siempre debió vivir. delete() devolviendo false constante es la forma más elegante de documentar que ni siquiera el admin borra pedidos — el dominio del cap. 16 convertido en código ejecutable.
Tres formas de preguntar, una sola verdad
<?php
// 1) Controlador — lanza 403 automático si niega:
public function update(Request $request, Pedido $pedido)
{
$this->authorize('update', $pedido); // Gate::authorize bajo el capó
$validos = $request->validate([
'estado' => ['required', Rule::in(config('pedidos.estados'))],
]);
$pedido->update($validos);
return new PedidoResource($pedido);
}
// 2) Blade — oculta lo que no corresponde:
// @can('update', $pedido)
// <a href="...">Marcar PAGADO</a>
// @endcan// 3) Rutas — antes de llegar al controlador:
Route::patch('/pedidos/{pedido}', ...)
->middleware(['auth:sanctum', 'can:update,pedido']);@can NO reemplaza al authorize() del servidor: decora la interfaz. La regla de oro del php_01 vale doble aquí: la UI sugiere, el backend decide.
El refactor visible: 422 se convierte en 403
TOKEN=... ; A="Accept: application/json"
curl -s -o /dev/null -w "%{http_code}\n" -X PATCH \
localhost:8000/api/pedidos/4 -H "$A" \\
-H "Authorization: Bearer $TOKEN" \\
-H "Content-Type: application/json" -d '{"estado":"PAGADO"}'
# pedido #2 ya está PAGADO: ahora lo decide la POLICY
curl -s -X PATCH localhost:8000/api/pedidos/2 -H "$A" \\
-H "Authorization: Bearer $TOKEN" \\
-H "Content-Type: application/json" -d '{"estado":"ANULADO"}'
En el cap. 29 ese segundo caso era ValidationException (422 «terminal»). Ahora la policy responde ANTES del validate() — y con el verbo correcto: no es un dato inválido, es una ACCIÓN prohibida. Semántica HTTP afinando el dominio.
Puntos clave
- Gate = acción suelta (can:gestionar-productos); policy = modelo.
- Convención Modelo→ModeloPolicy: cero registro manual.
- $this->authorize() lanza 403; @can solo decora la vista.
- delete() => false: reglas de dominio como código ejecutable.
- Prohibido ≠ inválido: policy (403) antes que validación (422).
33 · Cómo Laravel blinda tu app
Intermedio ~16 minEn php_01 construimos las defensas a mano: e() para escapar, prepared statements para SQL, token oculto para CSRF, validación exhaustiva. Trece capítulos después toca el recuento: cada una de esas batallas YA la pelea el framework — y hoy verificamos que no sea fe de erratas sino diseño.
- XSS: {{ }} escapa siempre; {!! !!} es una excepción justificada.
- SQLi: bindings automáticos y el único lugar donde puedes fallar.
- Mass assignment: $fillable rechazando campos contrabandeados.
- CSRF, cookies y hash: defaults que ya conoces por nombre.
XSS: el escape por defecto
<?php
// un cliente con humor dudoso se registra:
$cliente = Cliente::create([
'nombre' => '<script>alert("pwned")</script>',
]);<!-- en la vista: -->
<p>Cliente: {{ $cliente->nombre }}</p>
<!-- HTML generado (cap. 9): -->
<p>Cliente: <script>alert("pwned")</script></p>{{ }} ES nuestro helper e() — htmlspecialchars con comillas incluidas — aplicado
sin que lo pidas. La única puerta de entrada es {!! !!}, que imprime HTML
crudo: reservada para contenido TUYO de confianza (HTML ya saneado, embeds propios).
Jamás para input de usuario. El cap. 30 lo practicó en JS con textContent; aquí está
la versión servidor.
php artisan tinker
SQLi: bindings en todo el pipeline
<?php
// TODO esto viaja como parámetro vinculado, jamás concatenado:
Producto::where('nombre', $request->q)->get();
Pedido::estado($estado)->orderBy('fecha_pedido')->get();
DB::select('SELECT ... WHERE id = ?', [$id]);
// EL único lugar peligroso — SQL crudo con interpolación:
Producto::whereRaw("nombre LIKE '%$q%'"); // VULNERABLE
Producto::where('nombre', 'like', "%{$q}%"); // seguro: bindingCada where()/insert/update que escribiste desde el cap. 14 usó prepared statements sin anunciarse. El riesgo se concentra donde Eloquent te suelta la mano: whereRaw, orderByRaw, DB::statement. Regla operativa: si escribes SQL crudo, los datos van SIEMPRE en el array de bindings [?], nunca entre comillas interpoladas.
Mass assignment: el portero del create()
El atacante añadió total: 0.01 al formulario esperando colarlo en el
create() del store. $fillable (cap. 17) lo dejó en la puerta: solo nombre/precio/stock/
activo pasan. Sin esa lista, un campo malintencionado en un POST bastaba para alterar
montos. El MVC no tenía esta defensa — su create recibía $_POST completo tras filtrar
«a ojo».
CSRF y cookies: defaults activos
| Amenaza | Defensa Laravel (ya activa) | Eco php_01/MVC |
|---|---|---|
| XSS | {{ }} = e() automático | e() manual en cada echo |
| SQLi | bindings universales | stmt + bind_param a mano |
| CSRF | @csrf + verificación 419 (cap. 12) | token en $_SESSION casero |
| Mass assignment | $fillable/$guarded | filtros ad-hoc |
| Sesión fijada | regenerate() en login (cap. 31) | session_regenerate_id |
| Passwords | Hash::make bcrypt cost 12 | password_hash igual |
| Cookie de sesión | httponly + samesite=lax por default | configurar a mano |
Lo notable no es la lista — es que NINGUNA fila exige código tuyo más allá de usar el framework como se enseña desde el cap. 9. La seguridad aquí no es un módulo aparte: es el efecto secundario de escribir bien.
Puntos clave
- {{ }} = e(): escape por defecto; {!! !!} solo HTML propio.
- whereRaw con interpolación = la única puerta SQLi abierta.
- $fillable rechaza campos contrabandeados en create()/fill().
- CSRF/sesión/cookie/hash: defaults correctos sin configurar.
- Validar sigue siendo negocio: el escape no valida montos.
34 · Storage: discos y subida de archivos
Intermedio ~16 minEl MVC jamás subió un archivo: move_uploaded_file() hacia
public/uploads era territorio del servidor web y su URL era
adivinable por cualquiera. Laravel lo resuelve con una abstracción que ya
conoces de la casa: un CONTRATO. Un disco = driver + raíz; la API no cambia
aunque el archivo viva en tu laptop o en S3.
- Los discos local y public que vienen configurados.
- storage:link: el puente entre storage/app/public y la web.
- Subir con $request->file(): store(), storeAs() y hashName().
- Comprobantes privados: URLs firmadas que expiran.
Dos discos de fábrica
| Disco | Raíz física | Para qué |
|---|---|---|
| local (default) | storage/app/private | reportes, comprobantes: nadie los descarga por URL |
| public | storage/app/public | fotos de producto: accesibles vía enlace simbólico |
Ojo al detalle: desde hace unas versiones el disco local apunta a
storage/app/private, no a storage/app. Privado de verdad:
sin symlink no existe ruta HTTP que lo toque.
use Illuminate\Support\Facades\Storage;
// rutas SIEMPRE relativas a la raíz del disco:
Storage::disk('local')->put('reportes/hoy.json', '{"ok": true}');
// vive en storage/app/private/reportes/hoy.json
Storage::disk('s3')->put('reportes/hoy.json', '{"ok": true}');
// mismo método, otro planeta (requiere league/flysystem-aws-s3-v3)El disco público vive tras un enlace
Lo que hay en storage/app/public tampoco es visible por URL hasta que
creas el symlink public/storage:
A partir de ahí, asset('storage/productos/x.jpg') llega al archivo físico.
storage:unlink rompe el enlace; enlaces extra se declaran en la clave
links de config/filesystems.php.
La foto del producto: migración y formulario
php artisan migrate
// en la migración:
Schema::table('productos', function (Blueprint $table) {
$table->string('foto')->nullable()->after('nombre');
});<!-- resources/views/productos/edit.blade.php — el form necesita enctype: -->
<form method="POST" action="{{ route('productos.update', $producto) }}"
enctype="multipart/form-data">
@csrf
@method('PUT')
...
<input type="file" name="foto" accept="image/*">
@error('foto')
<p class="text-danger small">{{ $message }}</p>
@enderrorDel $_FILES al disco
// ProductoControlador@update — tras validate():
$datos = $request->validate([
'nombre' => ['required', 'max:120'],
'precio' => ['required', 'decimal:0,2', 'min:0'],
'stock' => ['required', 'integer', 'min:0'],
'foto' => ['nullable', 'image', 'max:2048'], // kilobytes
]);
if ($request->hasFile('foto')) {
if ($producto->foto) {
Storage::disk('public')->delete($producto->foto);
}
// store() genera nombre único; storeAs lo fija (demo reproducible):
$datos['foto'] = $request->file('foto')->storeAs(
'productos', 'teclado.jpg', 'public',
);
}
$producto->update($datos);Tres detalles finos. La regla image valida por MIME REAL del contenido —
la extensión fileinfo del cap. 2 trabajando; renombrar un .exe a .jpg no pasa.
max:2048 son KILOBYTES, no bytes. Y store() habría devuelto algo como
productos/aB3xK9...jpg: nombre único generado por hashName(), ideal para producción;
aquí fijamos el nombre con storeAs para que la demo sea reproducible.
Regla de seguridad: getClientOriginalName() y
getClientOriginalExtension() vienen DEL USUARIO — jamás decidas nada con ellas.
Usa hashName() y extension(), que derivan del contenido real.
Verificar y pintar
@if ($producto->foto)
<img src="{{ asset('storage/'.$producto->foto) }}"
alt="{{ $producto->nombre }}" class="img-thumbnail">
@endiftemporaryUrl('comprobantes/9.pdf', now()->plus(minutes: 30))
emite una URL FIRMADA que expira (en el disco local requiere 'serve' => true;
en S3 sale nativo). Misma API, dos niveles de intimidad.Puntos clave
- Un disco = driver + raíz; la API no cambia entre local y S3.
- local → storage/app/private (intimidad real); public → storage:link + asset().
- hasFile() + reglas image/max(KB) antes de mover un solo byte.
- store() nombra solo (hashName()); storeAs() fija nombre; nunca getClientOriginalName().
- Lo privado se comparte con temporaryUrl, no con URLs estables.
35 · Colas: trabajo diferido con workers
Intermedio ~18 minEn php_01 los scripts pesados se lanzaban con exec/nohup y una plegaria: sin reintentos, sin registro de fallos, sin prioridades. Laravel formaliza eso en tres piezas: el JOB (una clase con contrato), la COLA (una tabla que ya tienes) y el WORKER (un proceso que atiende la fila).
- QUEUE_CONNECTION=database sobre tablas de fábrica.
- make:job: constructor ligero, handle() con inyección.
- dispatch() + onQueue(): carriles con prioridad.
- queue:work --once/--queue; reintentos y failed_jobs.
- $afterCommit: colas dentro de transacciones sin sustos.
La infraestructura ya estaba instalada
Vuelve al output del primer migrate (cap. 14): entre las migraciones de fábrica
viajaba 0001_01_01_000002_create_jobs_table.php, que crea TRES tablas:
jobs (la fila), job_batches (grupos) y failed_jobs
(el cajón de lo reventado). Solo falta elegir driver:
QUEUE_CONNECTION=database
El job: una clase con contrato
<?php
namespace App\Jobs;
use App\Models\Pedido;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Support\Facades\Storage;
#[Timeout(60)] // novedad 13: atributo = $timeout clásico
class GenerarReporteVentas implements ShouldQueue
{
use Queueable;
public int $tries = 3; // intentos antes de rendirse
public int $backoff = 10; // segundos entre reintentos
public bool $afterCommit = true;
public function handle(): void
{
$resumen = Pedido::selectRaw('estado, count(*) as total')
->groupBy('estado')
->pluck('total', 'estado');
Storage::disk('local')->put('reportes/ventas.json', $resumen->toJson());
}
}El trait moderno Queueable hace por ti lo que los recursos del cap. 27 hacían a mano: si el constructor recibe un modelo, serializa solo su ID y lo vuelve a traer de la base al procesar. Por eso la regla es CONSTRUCTOR LIGERO: ids o escalares, jamás colecciones. Los atributos 13 son azúcar sobre propiedades clásicas — ambas formas son oficiales:
| Propiedad clásica | Atributo 13 | Semántica |
|---|---|---|
| public int $tries = 3; | #[Tries(3)] | máx. intentos |
| public int $backoff = 10; | #[Backoff(10)] | espera entre intentos |
| public int $timeout = 60; | #[Timeout(60)] | corte duro por ejecución |
| public bool $afterCommit | (interfaz) | esperar al commit |
Encolar: meter a la fila
dispatch(new GenerarReporteVentas) y
GenerarReporteVentas::dispatch() son equivalentes. Sin onQueue() va a la cola
default; con él eliges carril.
El worker: el empleado incansable
Sin --once el worker es un daemon eterno; con
--stop-when-empty se apaga al desocupar (útil en scripts). Con varios
carriles, la izquierda manda: --queue=reportes,default vacía reportes
antes de tocar default. Verificamos el producto del job:
Tres pagados (#1, #2, #4) y uno anulado (#3): coherente con toda la serie.
El reporte vive en storage/app/private/reportes/: íntimo, como prometió
el cap. 34.
Cuando el trabajo falla
Una excepción agota $tries → failed_jobs recibe payload y error. El ciclo de rescate:
php artisan queue:retry all # o retry {id}
php artisan queue:forget {id} # descartar para siempre
Prefieres «reintenta hasta las 17:00» antes que contar intentos? Método
retryUntil() devolviendo now()->plus(minutes: 30): gana sobre $tries.
$afterCommit = true retiene el job hasta el commit;
si hay rollback, nunca sale. Extras para cuando duelan: ShouldBeUnique (una sola
instancia en cola), #[DebounceFor(30)] (solo la última), ShouldBeEncrypted.Regla de operación: en producción el worker NO corre en screen/tmux — va bajo
supervisor (cap. 43), y tras cada despliegue recibe queue:restart para
tomar código nuevo.
Puntos clave
- jobs/job_batches/failed_jobs de fábrica; QUEUE_CONNECTION=database.
- Queueable serializa modelos por id: constructor ligero siempre.
- onQueue() elige carril; queue:work --queue=a,b prioriza la izquierda.
- Agotado el tries → failed_jobs; rescate con queue:retry.
- $afterCommit=true: jobs que respetan la transacción.
36 · Eventos y listeners: PedidoPagado
Intermedio ~15 minEn el MVC, el método que marcaba un pedido pagado hacía TODO dentro: actualizar el estado, mover stock y mandar correo, en línea y acoplados. El patrón observer separa dos preguntas: «algo pasó» (evento) de «a quién le importa» (listeners). Mañana agregas una tercera reacción sin tocar la primera.
- make:event: una bolsa de datos con el modelo dentro.
- Listeners auto-descubiertos por type-hint, cero registro.
- Síncrono vs ShouldQueue: qué corre en el request y qué en el worker.
- ShouldDispatchAfterCommit: eventos dentro de transacciones.
El evento es una bolsa de datos
<?php
namespace App\Events;
use App\Models\Pedido;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class PedidoPagado implements ShouldDispatchAfterCommit
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public Pedido $pedido,
) {}
}Sin lógica: contenedor puro. Dos rasgos ya conocidos hacen trabajo fino — SerializesModels guarda solo el ID del pedido (misma magia del Queueable cap. 35), y la interfaz ShouldDispatchAfterCommit (novedad aplicada) retiene el disparo hasta que la transacción haga commit; si hay rollback, el evento NUNCA sale. Es el hermano de $afterCommit para eventos. Si tu PATCH no usa transacción, se dispara al instante.
Listeners sin registro: discovery
<?php
namespace App\Listeners;
use App\Events\PedidoPagado;
use Illuminate\Support\Facades\Log;
class ActualizarEstadisticas
{
/**
* Handle the event.
*/
public function handle(PedidoPagado $event): void
{
Log::info('Pedido {id} pagado.', ['id' => $event->pedido->getKey()]);
}
}Nadie lo registró en ningún provider: Laravel escanea app/Listeners,
ve un método handle cuyo parámetro tipa PedidoPagado, y lo conecta solo. Verifícalo:
Ahora el segundo listener — el lento, el que NO debe frenar la respuesta HTTP. make:listener genera el esqueleto con ShouldQueue ya importado; basta activarlo:
// app/Listeners/EnviarRecibo.php (imports del job/mailable omitidos)
class EnviarRecibo implements ShouldQueue // corre EN EL WORKER, cap. 35
{
public function handle(PedidoPagado $event): void
{
Mail::to($event->pedido->cliente->email)
->send(new ReciboPedido($event->pedido)); // cap. 37
}
}Mismo evento, dos velocidades: ActualizarEstadisticas corre síncrono (barato, dentro del request) y EnviarRecibo aterriza en la tabla jobs para el worker. Si un listener necesita carril propio, los atributos #[Queue('emails')]/#[Delay(60)] lo ajustan — mismo vocabulario del cap. 35.
Disparar donde pasa
// Api\PedidoControlador@update — tras la transición validada (caps. 29 y 32):
if ($pedido->estado === 'REGISTRADO' && in_array($nuevoEstado, ['PAGADO', 'ANULADO'])) {
$pedido->update(['estado' => $nuevoEstado]);
if ($nuevoEstado === 'PAGADO') {
PedidoPagado::dispatch($pedido); // o dispatchIf($cond, $pedido)
}
}Probarlo en vivo
grep "pagado" storage/logs/laravel.log | tail -1
La fecha de la izquierda te la pone TU reloj; lo estable es el mensaje y su contexto estructurado entre llaves — el cap. 41 formaliza esa disciplina. El listener EnviarRecibo, en cambio, no imprimió nada aquí: está esperando en la tabla jobs a que un worker lo recoja.
Puntos clave
- El evento no sabe nada: datos públicos + traits del framework.
- Discovery por type-hint en app/Listeners: cero configuración.
- ShouldQueue mueve el listener al worker; sync queda en el request.
- ShouldDispatchAfterCommit: commit antes de gritar; rollback, silencio.
- Una reacción = job directo; dos reacciones = evento.
37 · Correo: Mailable y Mailpit
Intermedio ~14 minNi php_01 ni el MVC enviaron correo real — mail() sin remitente ni plantilla no es pieza de la que presumir. Aquí el correo es una clase TESTEABLE con vista propia, y en desarrollo no necesitas un servidor SMTP: primero escribimos los mensajes a disco, luego los vemos en una bandeja web.
- make:mail: envelope() + content(), el par moderno.
- Mail::to(...)->send(...) con datos por constructor.
- El driver log: cada mensaje escrito donde lees tus logs.
- Mailpit: bandeja visual para desarrollo.
El mailable: sobre y contenido
<?php
namespace App\Mail;
use App\Models\Pedido;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ReciboPedido extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public Pedido $pedido,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Recibo del pedido #'.$this->pedido->getKey(),
);
}
public function content(): Content
{
return new Content(
view: 'emails.recibo',
);
}
}Tres decisiones en una clase. El remitente NO se toca: sale de
config/mail.php (MAIL_FROM_ADDRESS/MAIL_FROM_NAME del .env).
Las propiedades públicas (nuestro $pedido) están disponibles DENTRO de la vista
sin pasarlas a mano — mismo convenio que las props de componentes Blade. Y al
implementar ShouldQueue, send() ENCOLA en vez de enviar síncrono: si tu listener
del cap. 36 ya es queued, elige UNA sola capa de cola o duplicarás trabajo.
La vista: Blade normal
<!-- resources/views/emails/recibo.blade.php -->
<h2>Gracias por tu compra, {{ $pedido->cliente->nombre }}</h2>
<p>Pedido {{ $pedido->estado }} · {{ now()->format('d/m/Y') }}</p>
<table>
@foreach ($pedido->detalles as $d)
<tr>
<td>{{ $d->producto->nombre }}</td>
<td>x{{ $d->cantidad }}</td>
<td>{{ moneda($d->precio_unitario * $d->cantidad) }}</td>
</tr>
@endforeach
<tr><th colspan="2">Total</th><td>{{ moneda($pedido->total) }}</td></tr>
</table>Blade completo: {{ }} escapa, helpers propios (moneda) y relaciones cargadas.
Si el correo fuera más formal, make:mail X --markdown=mail.x genera
plantillas con componentes x-mail::button/x-mail::panel listos para branding.
Enviar (sin servidor SMTP)
grep "^Subject:" storage/logs/laravel.log | tail -1
Mira tu .env: MAIL_MAILER=log. El driver log escribe el mensaje COMPLETO
(cabeceras MIME incluidas) en el log de la aplicación en lugar de enviarlo — cero
infraestructura para desarrollar, y constancia exacta de qué se habría mandado.
Mailpit: bandeja visual local
Cuando quieras VER el correo renderizado, Mailpit es un SMTP falso con interfaz:
# .env:
MAIL_MAILER=smtp
MAIL_HOST=127.0.0.1
MAIL_PORT=1025
// repetir el envío anterior y abrir en el navegador:
// http://localhost:8025 → bandeja con Recibo del pedido #4Puntos clave
- envelope() = sobre (asunto/remitente); content() = vista Blade.
- Props públicas del mailable llegan solas a la vista.
- MAIL_MAILER=log: desarrollo sin SMTP, mensajes en el log.
- Mailpit :1025 SMTP / :8025 UI para ver correos renderizados.
- ShouldQueue en el mailable = send() encola (una sola capa).
38 · Scheduler: tareas programadas
Intermedio ~13 minEn php_01 las tareas periódicas eran entradas crontab apuntando a scripts sueltos: la programación vivía FUERA del código y ningún repositorio la recordaba. Laravel invierte el orden: una sola línea genérica de crontab en el servidor, y TODO el horario vive versionado en tu proyecto.
- routes/console.php como casa del scheduler.
- Schedule::command/call con frecuencias declarativas.
- schedule:work para desarrollo sin tocar crontab.
- Producción: un cron que pregunta cada minuto, Laravel decide.
El horario vive en routes/console.php
<?php
use App\Jobs\GenerarReporteVentas;
use Illuminate\Support\Facades\Schedule;
// cada noche 23:50: despacha el job del cap. 35
Schedule::call(function () {
GenerarReporteVentas::dispatch();
})->dailyAt('23:50')->withoutOverlapping();
// domingos 04:00: limpiar reportes de más de 30 días
Schedule::command('reportes:limpiar --dias=30')->weeklyOn(0, '04:00');Dos formas: Schedule::call ejecuta una closure; Schedule::command apunta a un comando artisan (con make:command cuando la lógica merece clase — el informe pesado ya ES un job, aquí solo lo despachamos). Las frecuencias leen como horario de trenes: everyMinute(), hourly(), daily(), dailyAt('13:00'), weeklyOn(1, '8:00'), weekdays()->hourly()->between('8:00', '17:00').
Comandos inline en el mismo archivo
use Illuminate\Support\Facades\Artisan;
Artisan::command('pedidos:saluda {nombre}', function (string $nombre) {
$this->info("Hola, {$nombre}");
})->purpose('Saluda como los viejos scripts de php_01');Consola y programación conviven donde ya los buscas: el mismo archivo define comandos ad-hoc y horario.
Desarrollo: schedule:work
Nada de editar crontab en la laptop. El comando dedicado corre el scheduler en primer plano, evaluándolo cada minuto hasta que lo detengas:
php artisan schedule:work
# terminal 2 (inspección sin esperar):
php artisan schedule:list
schedule:list muestra cada tarea con su próxima ejecución. Para verla correr YA, cambia temporalmente la frecuencia a everyMinute() y observa la terminal.
Producción: UNA línea de crontab
* * * * * cd /ruta-a-pedidos && php artisan schedule:run >> /dev/null 2>&1
Cada minuto cron despierta a Laravel y él ejecuta SOLO lo que toca. Añadir una tarea nueva es un commit, no una sesión SSH. Dos modificadores que salvan noches: withoutOverlapping() evita ejecuciones solapadas cuando la tarea tarda más que su intervalo (lock por caché, expira en 24 h); onOneServer() evita duplicados si escalas a varios servidores — requiere caché compartida, y nuestro store database del próximo capítulo califica.
Puntos clave
- Horario versionado en routes/console.php; crontab genérico único.
- Schedule::call(closure) o ::command('firma --flags').
- schedule:work en dev; schedule:list para inspeccionar.
- * * * * * artisan schedule:run = toda la producción.
- Encolar jobs desde el scheduler, ejecutarlos en el worker.
39 · Caché: recuerda lo que cuesta
Intermedio ~13 minEl dashboard del cap. 25 ejecuta 8 consultas en cada visita, aunque las cifras cambien una vez por hora. La caché cierra la Parte VII con el patrón más rentable del framework: computa caro UNA vez, sirve barato muchas.
- El store database: tabla cache de fábrica, sin Redis.
- Cache::remember: leer-ó-computar en una línea.
- Invalidación al escribir: forget donde mutas datos.
- Cache::touch (novedad 13) y locks atómicos.
La infraestructura, otra vez de fábrica
Como jobs y sessions, la migración 0001_01_01_000001_create_cache_table.php
creó las tablas cache y cache_locks. El .env apunta ahí:
CACHE_STORE=database
Sin Redis instalado tienes caché real compartida por todos tus requests — y el mismo store sirve para los locks de withoutOverlapping() que viste en el cap. 38. Cuando escales de verdad, cambiar a redis es UNA variable; tu código no se entera (mismo contrato de siempre).
remember: el patrón completo en una línea
// PanelControlador — payoff del teaser del cap. 25:
$metricas = Cache::remember('panel.metricas', 300, function () {
return [
'pagados' => Pedido::where('estado', 'PAGADO')->count(),
'anulados' => Pedido::where('estado', 'ANULADO')->count(),
'criticos' => Producto::where('stock', '<=', 5)->count(),
'ingresos' => Pedido::where('estado', 'PAGADO')->sum('total'),
];
});
// primera visita: computa las 4 queries y GUARDA 5 minutos
// siguientes visitas: cero queries, respuesta instantáneaCache::get('clave', $default), Cache::put('k', $v, $segundos),
rememberForever(), pull() (lee Y borra) y add()
(escribe solo si no existe — operación atómica) completan el vocabulario básico.
Invalidar donde escribes
// Api\PedidoControlador@update — tras la transición a PAGADO:
$pedido->update(['estado' => 'PAGADO']);
Cache::forget('panel.metricas'); // el dashboard vuelve a computarLa regla que separa juguete de sistema: CADA punto que muta pedidos invalida la clave afectada. Olvidarlo significa dashboard contento con cifras viejas cinco minutos — o peores, si subes el TTL creyendo que «la caché sola se arregla».
Novedades 13 y extras
| Herramienta | Qué resuelve |
|---|---|
| Cache::touch('k', 3600) | novedad 13: extiende el TTL de lo ya guardado; false si la clave no existe |
| Cache::flexible('k', $stale, $fresh, fn) | sirve valor viejo mientras refresca en segundo plano |
| Cache::lock('venta', 10)->block(5, fn) | lock atómico: un solo proceso entra a la vez |
| php artisan cache:clear | vacía todo el store (dev/despliegues) |
Puntos clave
- CACHE_STORE=database: tablas cache/cache_locks de fábrica.
- remember(clave, segundos, closure) = leer-ó-computar-atribuir.
- forget() en cada punto de escritura: la invalidación es parte del dato.
- Cache::touch extiende vida (nuevo); flexible refresca sirviendo stale.
- Cachear lo caro, no lo frecuente; claves con contexto de usuario.
40 · Testing con Pest: la red de seguridad
Intermedio ~18 minEn php_01 testeabas funciones puras en milisegundos. Aquí el reto es otro: probar HTTP, base de datos y autenticación SIN ensuciar nada. Laravel trae el laboratorio armado: Pest por defecto (PHPUnit disponible), sqlite de pruebas y helpers para cada pieza que construimos.
- RefreshDatabase: cada test arranca limpio y rápido.
- Factories dentro de tests: datos deterministas por test.
- Probar CRUD web, API Sanctum y policies (403).
- Fakes: Storage::fake, Event::fake — sin efectos reales.
El laboratorio ya venía montado
El phpunit.xml de fábrica apunta a sqlite en memoria: cada test construye su mundo en RAM y lo tira. La «defensa sqlite» que mencionamos al configurar MariaDB (cap. 14) era esta. El trait RefreshDatabase migra una vez por proceso y envuelve cada test en transacción:
// tests/Feature/ProductoTest.php
use Illuminate\Foundation\Testing\RefreshDatabase;
use function Pest\Laravel\{get, post};
uses(RefreshDatabase::class);
it('lista productos activos', function () {
Producto::factory()->count(3)->create();
get('/productos')
->assertOk()
->assertSee('Teclado'); // seed del factory determinista
});Probar la API con llaves Sanctum
use Laravel\Sanctum\Sanctum;
it('crea producto autenticado', function () {
$user = User::factory()->create(['es_admin' => true]);
Sanctum::actingAs($user, ['*']); // sin tokens en texto: magia de test
$this->postJson('/api/productos', [
'nombre' => 'Cable HDMI', 'precio' => '29.90', 'stock' => 10,
])->assertCreated()
->assertJsonFragment(['nombre' => 'Cable HDMI']);
$this->assertDatabaseHas('productos', ['nombre' => 'Cable HDMI']);
});
it('rechaza escritura a anónimos', function () {
$this->postJson('/api/productos', [])->assertUnauthorized(); // 401
});La policy como contrato testeado
it('bloquea PATCH sobre pedido pagado', function () {
Pedido::factory()->create(['estado' => 'PAGADO']); // terminal
$user = User::factory()->create();
Sanctum::actingAs($user); // autenticado PERO...
$this->patchJson('/api/pedidos/1', ['estado' => 'ANULADO'])
->assertForbidden(); // la policy del cap. 32 manda
});Fakes: probar interacciones sin efectos
it('guarda la foto subida', function () {
Storage::fake('public');
$this->put('/productos/1', [
'nombre' => 'Teclado', 'precio' => '129.90', 'stock' => 10,
'foto' => UploadedFile::fake()->image('teclado.jpg'),
]);
// storeAs() del cap. 34 = nombre determinista:
Storage::disk('public')->assertExists('productos/teclado.jpg');
});
it('anuncia pagos', function () {
Event::fake([PedidoPagado::class]);
// ... PATCH que paga ...
Event::assertDispatched(PedidoPagado::class);
});Puntos clave
- Pest default: it()/test()+expect(); PHPUnit sigue disponible.
- RefreshDatabase + sqlite :memory: = mundo nuevo por test, en RAM.
- Sanctum::actingAs / actingAs: auth de test sin tokens reales.
- assertOk/Created/Forbidden/JsonFragment/assertDatabaseHas.
- Storage/Event/Queue/Mail fake: prueba el cableado, no los efectos.
41 · Depuración y observabilidad
Avanzado ~14 mindd() te acompaña desde el cap. 5, pero solo sirve cuando TÚ ya sabes dónde mirar. La observabilidad es lo contrario: la aplicación cuenta qué pasó mientras no estabas. Dos herramientas: logs estructurados y el query log.
- Canales: stack/single/daily y sus variables .env.
- Log::info con contexto estructurado interpolable.
- DB::listen: ver cada SQL que sale de Eloquent.
- Ecosistema: Telescope/Debugbar cuando el caso lo amerita.
Canales: una pila, varios destinos
Tu .env ya trae LOG_CHANNEL=stack: un canal que agrupa otros. Los dos
miembros típicos son single (un laravel.log) y daily (rota por día; retención con
LOG_DAILY_DAYS). En producción, daily evita el log de 2 GB:
LOG_CHANNEL=stack
LOG_LEVEL=debug # producción: info
LOG_DAILY_DAYS=14
use Illuminate\Support\Facades\Log;
// mensaje con placeholders + contexto estructurado:
Log::info('Pedido {id} pasado a {estado}.', [
'id' => $pedido->getKey(),
'estado' => $nuevoEstado,
]);
Log::warning('Stock crítico', ['producto' => $producto->getKey()]);
Log::error('Fallo cobrando', ['e' => $e->getMessage()]);Niveles RFC 5424: debug, info, notice, warning, error, critical, alert, emergency. El nivel del canal filtra: con LOG_LEVEL=info, los debug desaparecen sin borrar el código. El placeholder {id} se rellena desde el array — el mensaje queda legible Y el contexto sigue consultable como dato:
DB::listen: el contador de queries en esteroides
El presupuesto de queries del cap. 20 se vigilaba contando a mano. El listener de eventos de base de datos muestra CADA sentencia con bindings y duración:
// AppServiceProvider@boot — SOLO en desarrollo:
if (app()->isLocal()) {
DB::listen(function (QueryExecuted $query) {
Log::debug($query->sql, [
'bindings' => $query->bindings,
'ms' => $query->time,
]);
});
}Con esto, el N+1 deja de ser sospecha: aparece en el log con su 1+N líneas. En producción NO enciendas esto — multiplica el volumen de logs por cada query.
El ecosistema cuando hace falta más
| Herramienta | Qué aporta |
|---|---|
| Laravel Debugbar (dev) | queries/timeline/vistas por request, en pantalla |
| Telescope (local/staging) | x-ray de requests, jobs, mails, excepciones |
| Sentry/Flare (prod) | excepciones agregadas con stacktrace y alertas |
| dump()/dd() | seguir siendo el cuchillo suizo puntual |
Puntos clave
- stack agrupa canales; daily rota y LOG_DAILY_DAYS poda.
- LOG_LEVEL filtra los 8 niveles sin tocar código.
- Mensaje {placeholder} + array de contexto: legible y consultable.
- DB::listen en local = cada query visible con su costo.
- Debugbar/Telescope dev; Sentry para producción.
42 · Artisan a fondo: referencia operativa
Intermedio ~15 minCuarenta y un capítulos usando comandos a destajo merecen un mapa. Este capítulo NO enseña nada nuevo: consolida las familias que ya usaste, con la variante correcta para cada momento. Guárdalo como chuleta.
Familia make: qué genera cada letra
| Comando | Genera en | Recuerda |
|---|---|---|
| make:model Cliente -mfsc | app/Models + database/ | m=migración f=factory s=seeder c=controlador |
| make:migration create_x_table | database/migrations | --table=x para alter; ya aplicada = intocable |
| make:controller Web/XControlador --resource --model=X | app/Http/Controllers | --api quita create/edit; subcarpeta con barra |
| make:request GuardarPedido | app/Http/Requests | Form Request cuando validate() crece |
| make:event / make:listener --event=E | app/Events · app/Listeners | listener se auto-descubre por type-hint |
| make:job / make:mail / make:command | app/Jobs · app/Mail · app/Console | job ligero; mailable envelope+content |
| make:policy --model / make:resource X | app/Policies · app/Http/Resources | policy por convención Modelo→ModeloPolicy |
Base de datos: el ciclo completo
| Comando | Cuándo usarlo |
|---|---|
| migrate | aplica pendientes; jamás toca aplicadas |
| migrate:fresh --seed | DEV ONLY: borra TODO y reconstruye |
| migrate:rollback --step=1 | deshace el último lote aún no compartido |
| migrate:status | qué corrió y qué falta |
| migrate --force | producción: salta el prompt interactivo |
| db:show / db:seed / model:show X | inspección rápida sin abrir cliente SQL |
Cachés del framework: acelerar y limpiar
# agrupa config/event/route/view en archivos optimizados:
php artisan optimize
php artisan optimize:clear # o individualmente:
php artisan config:cache && php artisan route:cache && php artisan view:cache
# tras editar .env/config SIN cache activa no pasa nada;
# CON cache vieja, tus cambios son fantasmas hasta clear/cache:La regla del cap. 14 («credenciales fantasma») vive aquí: si cambias .env y no ves
el efecto, config:clear antes de culpar a MariaDB.
Colas y scheduler: operación diaria
| Familia | Comandos |
|---|---|
| queue: | work (--queue=a,b --once --stop-when-empty) · restart · failed · retry {id|all} · forget |
| schedule: | run (cron llama esto) · work (dev, primer plano) · list (inspección) |
| event:/route: | list (-v, --path=x, --method=GET) · event:list para discovery |
Mantenimiento e inspección
php artisan down --secret=paso-2026 # bypass vía /paso-2026 (cookie)
php artisan up # reabrir
php artisan about # salud completa (viste esto desde cap. 1)
php artisan tinker --execute="App\Models\Pedido::count();"
php artisan test --parallel # suite más rápida
php artisan list muestra todo,
php artisan help migrate detalla opciones de uno. La gramática del
cap. 1 (argumentos vs opciones, -n no-interactivo, -vvv verboso) aplica a TODA
esta tabla.Puntos clave
- fresh es dev-only; rollback --step para lotes propios; --force en prod.
- optimize agrupa los 4 cachés; config:clear ante «cambios fantasma».
- queue:work/restart/failed/retry = ciclo de vida del worker.
- down/up con --secret para ventana controlada (cap. 43 lo orquesta).
- list + help {comando}: nunca memorices lo consultable.
43 · Despliegue: la ventana de mantenimiento
Avanzado ~16 minTodo el manual converge aquí. Desplegar no es «subir archivos»: es una coreografía ordenada donde NADIE ve a medias y ningún job queda huérfano. La regla de la casa desde el MVC: jamás migrar con usuarios dentro.
- down --secret: cerrar la puerta con llave para ti.
- El orden exacto: código → migración → cachés → workers → up.
- Supervisor: el worker como servicio supervisado.
- Checklist de producción: lo que NO se improvisa.
La coreografía completa
php artisan down --refresh=15 --retry=60 --secret=paso-servicio-2026
# 2. Pausar workers SIN perder el job en curso:
php artisan queue:restart
# 3. Código nuevo + dependencias:
git pull && composer install --no-interaction
# 4. Esquema, sin prompt:
php artisan migrate --force
php artisan optimize
# 6. Workers toman código nuevo al relanzarse:
sudo supervisorctl restart pedidos-worker:* # 7. Reabrir: php artisan up
Paso 1 merece detalle: mientras dure el mantenimiento todos ven el 503 (con
reintentos automáticos cada --retry=60 segundos gracias al meta refresh), pero
navegar UNA vez a /paso-servicio-2026 te emite una cookie de bypass y
te redirige al inicio — puedes verificar el sitio como usuario normal. El estado
del modo vive en un archivo bajo bootstrap/cache/ (por eso exigimos ese permiso
en el cap. 2); en multi-servidor, APP_MAINTENANCE_DRIVER=cache lo comparte.
Los jobs en cola NO se procesan durante el modo mantenimiento.
Supervisor: el worker como servicio
; /etc/supervisor/conf.d/pedidos-worker.conf
[program:pedidos-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/pedidos/artisan queue:work database --sleep=3 --tries=3
numprocs=2
autostart=true
autorestart=true
stopwaitsecs=3600
user=www-dataDos procesos que MariaDB alimenta; si uno muere, supervisor lo revive. El
stopwaitsecs=3600 respeta los jobs largos antes de matar. Tras cada
despliegue, queue:restart (paso 2) les dice «termina tu job actual y sal» —
supervisor los levanta ya con el código nuevo. Por eso el paso 6 reinicia:
restart explícito es más predecible.
Checklist de producción
| Ítem | Por qué |
|---|---|
| APP_DEBUG=false · APP_ENV=production | el stacktrace es mapa para atacantes |
| APP_KEY intacto entre despliegues | rotarla rompe sesiones y cifrado |
| Permisos storage/ y bootstrap/cache/ | logs, vistas compiladas, estado down |
| Backup ANTES de migrate --force | rollback de datos no existe |
| Health check /up monitoreado | 200 = app viva (cap. 1) |
| LOG_LEVEL=info + LOG_CHANNEL daily | ruido controlado, rotación automática |
| 503 propia: resources/views/errors/503.blade.php | maintenance con cara de tu marca |
Puntos clave
- down --secret cierra al mundo, te deja pasar con cookie.
- Orden sagrado: down → queue:restart → código → migrate --force → optimize → up.
- Supervisor numprocs revive workers; stopwaitsecs respeta jobs.
- queue:restart tras deploy: código nuevo sin perder el job actual.
- Checklist antes que memoria: producción no perdona.
44 · Rumbo a la meta: mapa y graduación
Meta ~12 minTercera vuelta completada. El dominio Pedidos que construiste a mano en el MVC y le pusiste ORM en Eloquent existe ahora COMPLETO dentro de un framework: migraciones, Blade, API con Sanctum, auth, policies, colas, eventos, correo, scheduler, caché, tests y despliegue. Cerremos el mapa.
El recorrido por partes
| Parte | Caps | Lo que dominas |
|---|---|---|
| I · Del standalone al framework | 1–7 | ciclo de vida, artisan, config/.env, helpers, rutas |
| II · Núcleo HTTP | 8–13 | controladores, Blade I/II, middleware, formularios+CSRF, sesiones |
| III · Base de datos | 14–19 | MariaDB, migraciones espejo de tienda_orm, modelos, factories, relaciones |
| IV · CRUD web | 20–25 | binding, transacción con lockForUpdate, scopes, paginate(), dashboard |
| V · API REST | 26–30 | rutas sin estado, Resources JSON, Sanctum, endpoints Pedidos, fetch |
| VI · Seguridad | 31–34 | login artesanal, policies/gates, blindaje XSS/SQLi/CSRF, Storage |
| VII · Servicios | 35–39 | colas database, eventos, mailables, scheduler, caché |
| VIII · Calidad y producción | 40–43 | Pest feature/API/policy, logs y query log, Artisan a fondo, despliegue |
La traducción definitiva: artesanal → Laravel
| Lo hacías a mano (MVC/Eloquent) | Laravel lo hace |
|---|---|
| front controller + preg_match de rutas | Route:: + Route::resource (cap. 8) |
| include/layout() de plantillas | @extends/@yield y componentes x- |
| e() en cada echo | {{ }} escapa siempre |
| $_SESSION y session_regenerate_id | session()->put/regenerate (caps. 13·31) |
| PDO preparado a mano | Eloquent + bindings universales |
| paginar() artesanal | paginate(10)+withQueryString() |
| token CSRF casero | @csrf / PreventRequestForgery |
| script cron suelto | Schedule::command()->dailyAt() |
| nohup para lo lento | jobs + queue:work bajo supervisor |
| backup antes de tocar SQL | migrate --force tras down --secret |
Examen de graduación: módulo Facturación
El cierre honesto es construir algo SIN guía paso a paso. Requisito: extender Pedidos con facturas — factura pertenece a pedido (1:1 al pagar), con número correlativo F001-0001 e importe congelado. Entregables:
- Migraciones con FK RESTRICT + modelo Factura con casts decimal.
- Factory determinista y seeder que factura los pedidos PAGADO.
- CRUD web con policy (solo admin emite; anulación = estado propio).
- Endpoint API index/show con Resource + test Sanctum assertJsonFragment.
- Evento FacturaEmitida + listener ShouldQueue que envía mailable (log driver).
- Test feature del flujo completo: PATCH paga pedido → factura existe → Mail::fake la vio.
Si los seis puntos salen sin releer el manual, no graduaste un curso: reconstruiste tu forma de trabajar sobre un framework.
Rutas futuras
- Livewire v3: reactividad sin escribir el fetch del cap. 30.
- Inertia + React/Vue: SPA con routing Laravel de lado servidor.
- Symfony comparativo: ver el mismo problema en otro dialecto.
- Serie IA: el AI SDK estable de Laravel 13 merece manual propio.
Puntos clave
- 44 caps, 8 partes: del front controller al despliegue supervisado.
- La tabla artesanal→Laravel es tu diccionario bilingüe permanente.
- Facturación: el examen mide independencia, no memoria.
- Livewire/Inertia/Symfony/IA: siguientes estaciones, no urgencias.