Índice del curso

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.

44 capítulos Bootstrap 5.3 Modo claro / oscuro Optimizado para móvil Laravel 13 · PHP 8.3+
44
Capítulos
150+
Ejemplos de código
3
Niveles: básico a experto
3
Prerrequisitos: PHP · MVC · Eloquent
Cómo usar este tutorial: sigue los capítulos en orden (el índice está en el menú si lees desde el móvil). Cada pieza del framework la reconocerás: su versión casera vive en los manuales PHP, MVC y Eloquent. El framework no borra ese aprendizaje — le pone mantenimiento, herramientas y años de pruebas encima.

1 · Por qué Laravel y qué resuelve

Básico ~14 min

Llevamos 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.

PiezaNuestra versión artesanalEn Laravel
Front controller + rutasindex.php con switch/regex propiopublic/index.php + Router
Contenedor de serviciosclase contenedora propiaService Container (auto-resolución)
Vistasincludes + extract + plantillasBlade: herencia y componentes
Persistenciarepositorio PDO / Capsule standaloneEloquent integrado
Esquema de BDscript tienda_orm.sql únicomigraciones versionadas + seeders
Validaciónbolsa de errores con filter_varValidator + Form Requests
XSShelper e() propioBlade escapa automáticamente
Sesiones y flash$_SESSION envuelto a manoSession + redirects with()
Tareas pesadasscripts CLI con señales pcntlcolas + 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"
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í.
Ecosistema first-party: además del núcleo, Laravel mantiene Sanctum (tokens API), Telescope/Debugbar (depuración), Horizon (vigilancia de colas), Cashier (suscripciones)… y publica una versión mayor cada año. Este manual apunta a Laravel 13 (marzo de 2026): PHP 8.3 como mínimo, atributos nativos opcionales y config tipada — lo iremos señalando donde corresponda, nunca como museo.

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 min

Ya 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, xml

Má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 -v | head -1
php -m | grep -icE '^(ctype|curl|dom|fileinfo|filter|hash|mbstring|openssl|pdo|pdo_mysql|session|tokenizer|xml)$'
PHP 8.5.0 (cli) 13

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

# vía A: installer oficial (recomendada)
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.

cd pedidos
php artisan serve
INFO Server running on [http://127.0.0.1:8000]. Press Ctrl+C to stop.

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

CaminoPlataformaQué aportaIdeal si…
HerdmacOS / Windowsoficial; PHP + nginx sin configquieres cero fricción y pagas extras
LaragonWindowstodo-en-uno, multi-PHP, portablevives en Windows con varios proyectos
XAMPPcruzadoApache + MariaDB + PHP clásicossolo necesitas replicar hosting viejo
PPA Ondřej + ComposerLinuxcontrol total, servicios realesnuestra 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 mantenimiento

En 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 chown -R www-data:www-data storage bootstrap/cache
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:

php artisan about
ENVIRONMENT ................................................................ Application name ........................................... Pedidos Laravel version ............................................ 13.21.4 PHP version ................................................ 8.5.0 Environment ................................................ local Debug mode ................................................. ENABLED URL ........................................................ localhost Maintenance mode ........................................... OFF CACHE ...................................................................... Config, routes, views ...................................... NOT CACHED DRIVERS .................................................................... Database driver ............................................ sqlite Session driver ............................................. database Queue driver ............................................... database (salida abreviada)

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 min

Abrir 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 artesanalSu hogar en LaravelSe explica a fondo en
front controller propiopublic/index.php + Routercap. 4 (hoy)
rutas en switch/regexroutes/web.php declarativocap. 7
contenedor de clases propioService Containercap. 8
vistas con extract/includeresources/views/*.blade.phpcaps. 9–10
repositorios PDOapp/Models + Eloquentcaps. 14–19
script tienda_orm.sqlmigrations + factories + seederscaps. 15–16 y 19
helper e() y validadorBlade escapa solo + Validatorcaps. 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 → navegador

Compá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.

Tres hábitos desde hoy: vendor/ se reconstruye con 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 min

Ya 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 opciones

Argumentos = 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:

FlagEfectoCuándo vive bien
-h, --helpmanual del comando: opciones propias incluidassiempre, antes que buscar en internet
-n, --no-interactionresponde "no" a todo aviso interactivodespliegues y cron (nadie mirará la pantalla)
-qsilencio totaltareas programadas que solo interesan si fallan
-v / -vv / -vvvverbosidad normal / detallada / debug completo-vvv imprime stack trace íntegro ante errores
--ansi / --no-ansicolores on/offlogs donde el color ensucia
Hábito nuevo: antes de preguntar qué hace un comando, 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
php artisan list make
Available commands: about Display basic information about your application ... db Start a new database CLI session migrate Run the database migrations serve Serve the application on the PHP development server tinker Interact with your application Available commands for the "make" namespace: make:model Create a new Eloquent model class make:migration Create a new migration file make:controller Create a new controller class ... (recortado)

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:

php artisan tinker
Psy Shell v0.12.8 (PHP 8.5.0 — cli) by Justin Hileman > config('app.name') = "Pedidos" > now()->format('d/m/Y H:i') = "24/08/2026 09:41" > app()->version() = "13.21.4" > exit

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 min

En 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=database

Segundo 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:

php artisan tinker
> config('app.name') = "Pedidos" > config('app.env') = "local" > config('session.driver') = "database" > config('pedidos.igv', 0.0) ← segundo arg = default si no existe = 0.0

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/

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:

php artisan tinker
> config('pedidos.moneda') = "PEN" > config('pedidos.estados')[1] = "PAGADO" > exit

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.

Y cuando cambies el .env… en desarrollo se refleja al instante; en producción hay que limpiar la caché de configuración (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 min

En 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

HelperDevuelveEjemplo
route('pedidos.ver', ['id' => 7])URL generada por nombre"/pedido/7"
view('pedidos.lista')vista lista para renderizarcap. 9
asset('css/app.css')URL pública desde /"http://.../css/app.css"
old('correo')valor previo del formulariocap. 12
csrf_token()token anti-falsificacióncap. 12
config('pedidos.igv')valor de configuracióncap. 5
logger('mensaje')escribe en storage/logscap. 41
now()Carbon actualvisto en tinker (cap. 4)
dump() / dd()inspeccionar y seguir / inspeccionar y morireco 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/"
        }
    }
}
composer dump-autoload

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:

php artisan tinker
> moneda(321.05) = "S/ 321.05" > etiqueta_estado('PAGADO') = "success" > exit

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 globalvistas y plantillas: brevedad mandacargado siempre; global real
clase inyectablereglas de negocio, tests, serviciosimport + instancia (o estáticos)
macro de Collectiontransformación reutilizable de listasregistro ú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 min

Nuestro 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/saludo/Percy
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
Hola, Percy {"id":7,"estado":"REGISTRADO","total":321.05} 404

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étodoAceptaEco 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 regexel caso general
{id?} con defaultpará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:

php artisan tinker
> route('pedidos.ver', ['id' => 7]) = "http://127.0.0.1:8000/pedido/7" > exit

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');
});
curl -s http://127.0.0.1:8000/admin/reportes
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.

Inspección oficial: 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 min

Las 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

# básico: clase vacía
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 MVCResource LaravelVerbo + URI
listar()index()GET /productos
crear() — formulariocreate()GET /productos/create
guardar() — recibe POSTstore()POST /productos
ver($id)show($id)GET /productos/{producto}
editar($id) — formularioedit($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);
php artisan route:list --path=productos
GET/HEAD productos ................. productos.index GET/HEAD productos/create .......... productos.create POST productos ................. productos.store GET/HEAD productos/{producto} ...... productos.show GET/HEAD productos/{producto}/edit . productos.edit PUT/PATCH productos/{producto} ...... productos.update DELETE productos/{producto} ...... productos.destroy (recortado)

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.

Novedad 13 confirmada: el middleware también puede declararse como atributo PHP sobre la clase o el método — #[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 min

Nuestro 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>
@endforeach

Fíjate: DENTRO de {{ }} hay PHP completo — ahí reutilizamos los helpers del capítulo 6 sin importarlos. El resultado:

curl -s http://127.0.0.1:8000/demo/lista
<h1>Pedidos</h1> <p> #1 — Ana Quispe <span class="badge text-bg-success"> PAGADO </span> S/ 258.20 </p> ... (2 filas más)

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 plantillaEl navegador recibeConsecuencia
{{ $comentario }}&lt;script&gt;...&lt;/script&gt;se MUESTRA como texto: inofensivo
{!! $comentario !!}&lt;script&gt;...&lt;/script&gt;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 / lasttrue en el primer / último elemento
even / oddpara zebra en tablas
counttotal 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 min

En 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

php artisan make:component Alerta
# → 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>
<x-alerta tipo="warning" class="mt-3">
   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 -estilo son HTML válido y el editor los autocompleta si publicas los stubs.

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

HerramientaSirve paraEjemplo
@includeparcial con datos explícitos@include('pedidos.fila', ['p' => $p])
@eachparcial por elemento + vacío@each('pedidos.fila', $pedidos, 'p', 'pedidos.vacia')
@push/@stackCSS/JS que SOLO cierta página cargavisto en el layout de arriba
@onceempujar 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ónHerramienta
esqueleto global simple, pocas páginas@extends/@yields
piezas con lógica/props reutilizablescomponente con clase
badges, iconos, tarjetas simplescomponente 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 min

Cuando 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

php artisan make:middleware FirmarRespuesta
# → 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é

NivelSe aplica a…Ejemplo Pedidos
->append()absolutamente todofirma de respuesta, logs de performance
grupo web/apitoda la zona correspondientesesión+CSRF vs stateless
->middleware('alias')rutas específicasRoute::get(...)->middleware('admin')
grupo propiomódulo enteroRoute::middleware('admin')->group(...)
curl -sI http://127.0.0.1:8000/saludo/Percy | grep -i firma
X-Pedidos-Firma: webcode

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')].

Debug express: ¿tu middleware no se ejecuta? 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 min

El 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.
curl -s -X POST http://127.0.0.1:8000/clientes \
  -H "Accept: application/json" -d "nombre=&correo=malo"
# sin Accept: application/json habría redirect 302 con errores en sesión
{"message":"The correo field must be a valid email address.", "errors":{ "nombre":["The nombre field is required."], "correo":["The correo field must be a valid email address."]}} (estado HTTP: 422)

Las reglas que usarás el 90% del tiempo

ReglaExige…Ojo con…
required / nullableobligatorio / puede venir vacíonull ≠ '' : decide por campo
string / integer / numerictipo del valorinteger NO recorta decimales
min:x / max:xmínimo/máximostrings = caracteres; números = VALOR (usa digits:x)
email / date / urlformato válidodate acepta lo parseable por strtotime
confirmedcampo_confirmation igualtípico en password
unique:tabla,colno exista aúnal EDITAR: Rule::unique()->ignore($id)
exists:tabla,colsí exista (FK lógica)nuestro pedido_detalles.producto_id
in:a,b,cvalor de la listamejor Rule::in(config(...))
bailparar al primer fallo del campomensajes 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:

# .env
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',
    ],
];
El campo correo electrónico es obligatorio.

Los dos puntos :attribute/:max son marcadores que el validador sustituye — mismo patrón que nuestros sprintf() de mensajes en el MVC.

Cuando crezca: si store() y update() comparten reglas largas, extrae un Form Request (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 min

El 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 MVCAquí
$_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 -c galletas.txt -o /dev/null http://127.0.0.1:8000/demo/flash
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
Aviso: Pedido guardado con éxito Aviso: (nada)

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.

Seguridad: al iniciar sesión de verdad (cap. 31) llamaremos $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 min

Abrimos 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

mysql -u root -p
> CREATE DATABASE pedidos_laravel CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; > CREATE USER 'pedidos_app'@'localhost' IDENTIFIED BY 'clave-fuerte-aqui'; > GRANT ALL PRIVILEGES ON pedidos_laravel.* TO 'pedidos_app'@'localhost'; > FLUSH PRIVILEGES;

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

# 1. El doctor de bases
php artisan db:show

# 2. Consulta viva desde tinker
php artisan tinker
Database ................................ mysql Host ................................... 127.0.0.1 Database ............................... pedidos_laravel Tables ................................. 0 (recortado) > DB::select('SELECT DATABASE() AS base') = [{#4521 +"base": "pedidos_laravel", }] > exit

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.

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 min

tienda_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

php artisan make:migration create_clientes_table
# → 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

BlueprintSQL 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

php artisan migrate
0001_01_01_000000_create_users_table .............. DONE 0001_01_01_000001_create_cache_table ............... DONE 0001_01_01_000002_create_jobs_table ................ DONE 2026_08_24_101500_create_clientes_table ............ DONE

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:status | tail -n 6
php artisan migrate:rollback # revierte SOLO el último batch
php artisan migrate # vuelve a aplicar
Ran? ..................................... Migration ....... Batch Yes .................................. create_clientes_table ... 1 Rolling back: 2026_08_24_101500_create_clientes_table Rolled back: 2026_08_24_101500_create_clientes_table (0.01s)
Nunca edites una migración ya ejecutada en una base compartida: quien tenga el hash viejo no recibirá tus cambios. Cambios posteriores = migración NUEVA (add_correo_verificado_to_clientes_table, change_xxx con ->change()). En tu máquina, si aún es dev temprano, migrate:fresh (cap. 16) reinicia todo limpio.

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 min

clientes 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

ComandoHaceCuándo
migrateaplica pendientessiempre, primero
migrate:freshDROP ALL + re-ejecuta TODOdev temprano (--seed en cap. 18)
migrate:rollback --step=1deshace último batchte equivocaste recién
migrate:resetrevierte todo históricoraro; casi siempre prefieres fresh
migrate:statusran? / pendientes / batchesauditar antes de desplegar
php artisan migrate
2026_08_24_102100_create_productos_table ............ DONE 2026_08_24_102200_create_pedidos_table ................ DONE 2026_08_24_102300_create_pedido_detalles_table ........ DONE

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 min

En 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

php artisan make:model Cliente -fs
# → app/Models/Cliente.php
# → database/factories/ClienteFactory.php (cap. 18)
# → database/seeders/ClienteSeeder.php (cap. 18)
BanderaGenera además
-m / --migrationmigración nueva
-f / --factoryfactory para datos de prueba
-s / --seedseeder
-c / --controllercontrolador
-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',
    ];
}
Novedad 13 confirmada en docs: lo mismo como atributos PHP — #[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:

php artisan tinker
> App\Models\Producto::create([ 'nombre' => 'Teclado mecánico', 'precio' => 129.90, 'stock' => 15, 'activo' => true, 'producto_id' => 9999, ← intento de colarse ]) = App\Models\Producto {#4512 producto_id: 1, ← ignorado: no estaba en $fillable ... }

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:

php artisan model:show Producto
App\Models\Producto ⚡ Database ................. MariaDB @ pedidos_laravel Table .................... productos Primary Key .............. producto_id Fillable ................. nombre, precio, stock, activo Casts .................... precio → decimal:2 · activo → boolean (recortado)

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 min

En 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',
        ];
    }
}
php artisan tinker
> App\Models\Cliente::factory()->count(3)->sequence( ['nombre' => 'Ana Quispe', 'correo' => 'ana@ejemplo.pe'], ['nombre' => 'Luis Ramos', 'correo' => 'luis@ejemplo.pe'], ['nombre' => 'Carmen Rojas', 'correo' => 'carmen@ejemplo.pe'], )->create() = App\Models\Eloquent\Collection {#4521} > exit

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']);
}
# uso legible como prosa:
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,
    ]);
}
php artisan db:seed
# o el combo de desarrollo diario:
php artisan migrate:fresh --seed
Seeding database. Database\Seeders\ClienteSeeder .................... DONE Database\Seeders\ProductoSeeder ................... DONE

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 min

En 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

php artisan tinker
> $p = App\Models\Pedido::find(1) > $p->cliente->nombre = "Ana Quispe" > $p->detalles->count() = 2 > $d = $p->detalles->first() > [$d->cantidad, $d->producto->nombre] = [2, "Teclado mecánico"]

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 filas

Anidado 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) igual
php artisan migrate:fresh --seed
php artisan tinker
> App\Models\Pedido::with('cliente')->get(['pedido_id','estado','total']) [{"pedido_id":1,"estado":"PAGADO","total":305.30}, {"pedido_id":2,"estado":"REGISTRADO","total":45.50}, {"pedido_id":3,"estado":"ANULADO","total":129.90}]

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 min

Abre 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

php artisan make:controller Web/PedidoControlador --resource --model=Pedido
# 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-22

Solo/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 -->
# HTML plano para leer fácil:
curl -s http://127.0.0.1:8000/pedidos | grep -E 'href|badge|S/' | head -n 4
<a href="http://127.0.0.1:8000/pedidos/1">#1</a> Ana Quispe <span class="badge text-bg-success">PAGADO</span> S/ 305.30

El detalle y sus tres consultas

curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/pedidos/1
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/pedidos/999
200 404

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.

Audita tu presupuesto: con Debugbar (cap. 41) verás el conteo real de queries por página. La disciplina with()-siempre se vuelve adicción cuando la barra te lo recuerda en rojo.

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 min

La 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:

# sin token CSRF el POST directo muere antes (cap. 12):
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
419 > App\Models\Pedido::count() ← tras el intento fallido desde el form = 3 > App\Models\PedidoDetalle::count() = 4

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.

Refactor futuro anunciado: store() ya pide a gritos un Form Request (cap. 12) y una clase de servicio dedicada. Lo haremos cuando toque tests (cap. 40) — primero verlo funcionando, después embellecerlo. Igual que aprendimos a escribir SQL antes que Eloquent.

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 min

El 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 SIN ventas (Hub USB-C, id 4) — borra limpio
# producto CON ventas (Teclado, id 1) — mensaje de error
php artisan tinker
> App\Models\Producto::find(4)->delete() = true > App\Models\Producto::find(1)->delete() Illuminate\Database\QueryException SQLSTATE[23000]: ... FOREIGN KEY constraint fails (pedido_detalles.producto_id) > App\Models\Producto::count() = 29

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 min

Cada 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);
}
php artisan tinker
> App\Models\Pedido::estado('PAGADO')->count() = 1 > App\Models\Producto::activos()->conStock()->orderBy('nombre') ->pluck('nombre') = ["Hub USB-C", "Mouse inalámbrico", "Teclado mecánico"]

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

php artisan tinker
> $ana = App\Models\Cliente::find(1) > $ana->pedidos()->estado('ANULADO')->count() = 1 > $ana->pedidos->count() = 2

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.

Prueba de olfato: si el filtro responde «¿qué quiero ver ahora?», es local. Si responde «cómo ES este modelo siempre», evalúa global — y documéntalo en la cabecera del modelo para no sorprender a nadie.

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 min

El 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ágina

Dato 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étodoConsultasÚsalo cuando…
paginate(10)COUNT + SELECTnecesitas «1–10 de 30» y números de página
simplePaginate(10)solo SELECTbasta «Anterior/Siguiente» — ahorra el COUNT
cursorPaginate(10)WHERE > cursorvolumen 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:

# database/seeders/ProductoSeeder.php, tras los 4 reales:
\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]'
Producto demo 14 Producto demo 15

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 min

Cierre 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 artisan make:controller PanelControlador --invokable
<?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
# clases .tarjetas/.tarjeta en tu CSS (grid de 4 columnas);
# aquí solo importa el DATO. Verificación rápida:
curl -s http://127.0.0.1:8000/panel | grep -oE '<b>[^<]*</b>'
<b>3</b> <b>1</b> <b>S/ 305.30</b> <b>26</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.

Cuando crezca: estos agregados son candidatos perfectos a caché (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 min

Abre 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 install:api
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:

php artisan route:list --path=api
GET api/user ................................................. Api\...

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 flashnada 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, componentessolo 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.

php artisan make:controller Api/ProductoControlador --api --model=Producto

Hablar JSON: el header Accept

# SIN el header: Laravel responde como web (redirect/html)
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
HTTP/1.1 422 Unprocessable Content {"message":"The precio field must be at least 0.","errors":{"precio":["The precio field must be at least 0."]}}

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;
}
curl -s http://127.0.0.1:8000/api/productos/1
{"producto_id":1,"nombre":"Teclado mecánico","precio":"129.90", "stock":8,"activo":true,"created_at":"2026-08-24T12:00:00.000000Z", "updated_at":"2026-08-24T12:00:00.000000Z"}

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 min

El 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

php artisan make:resource ProductoResource
# 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();
curl -s "http://127.0.0.1:8000/api/productos?page=3" \
  | python3 -m json.tool | head -n 24
{ "data": [ {"id": 21, "nombre": "Producto demo 17", ...}, ... ], "links": { "first": "http://127.0.0.1:8000/api/productos?page=1", "last": "http://127.0.0.1:8000/api/productos?page=3", "prev": "...page=2", "next": null }, "meta": { "current_page": 3, "from": 21, "last_page": 3, "per_page": 10, "to": 30, "total": 30 } }

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

Laravel 13 trae de fábrica recursos conformes a la especificación JSON:API: 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 min

Nuestra 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:

php artisan tinker
> $u = App\Models\User::create(['name' => 'Percy', 'email' => 'percy@pedidos.pe', 'password' => Hash::make('secreto123')]) > $t = $u->createToken('cli-local') > $t->plainTextToken = "3|9fZc..."

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']);
});
A="Accept: application/json"
# 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}'
401 201

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 ability

Diferencia 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».

Otro modo de usar Sanctum: para SPAs propias no hay tokens, sino cookies de sesión vía statefulApi() + /sanctum/csrf-cookie. Es decir: Sanctum también protege tu frontend Vue/React del mismo dominio. Lo retomaremos al hablar de autenticación (Parte VI).

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 min

Todo 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

TOKEN="3|9fZc..."
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"
{"data":{"id":4,"estado":"REGISTRADO","total":"45.50",...}} {"data":{"id":2,"estado":"PAGADO","total":"45.50",...}} {"meta":{"current_page":1,"total":2,...}} ← #1 y #2

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 min

Cierre 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>
@endpush

Tres 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?

Aquí no hace falta: la demo vive en http://127.0.0.1:8000 y llama a ese MISMO origen — same-origin, sin CORS. Solo si tu frontend corre en otro puerto/dominio (Vite :5173, React :3000) Laravel debe mandar headers Access-Control-Allow-*: publica config/cors.php con php artisan config:publish cors y lista tus orígenes permitidos.
# verificación rápida de la demo (server corriendo):
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 10

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 min

El 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');
}
php artisan tinker
> $h = App\Models\User::find(1)->password = "$2y$12$7Kq..." > Illuminate\Support\Facades\Hash::check('secreto123', $h) = true

$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.

Sobre los starter kits: Laravel recomienda Breeze/Jetstream como andamios listos — pero la doc oficial admite que instalarlos SOLO para leerlos es un gran ejercicio: contienen exactamente este patrón (validate → attempt → regenerate → intended). Tú ya lo escribiste; ahora puedes leerlos como quien lee su propio código traducido. Nota honesta: el throttle de intentos fallidos viene CON los kits; a mano, añádelo tú con throttle:6,1 en la ruta POST.
# demo determinista (server corriendo):
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://127.0.0.1:8000/panel
302 http://127.0.0.1:8000/ingresar

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 min

Autenticado 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 artisan make:migration agrega_es_admin_a_users --table=users
<?php
Schema::table('users', function (Blueprint $table) {
    $table->boolean('es_admin')->default(false)->after('password');
});
// down: $table->dropColumn('es_admin');
php artisan migrate
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:sanctum

Policy: el contrato del pedido

php artisan make:policy PedidoPolicy --model=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

# pedido #4 sigue REGISTRADO: transición válida
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"}'
200 {"message":"This action is unauthorized."} (HTTP 403)

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.

Extras oficiales cuando crezcas: before($user, $ability) en la policy para superadmins; Response::deny('motivo') para mensajes propios; denyAsNotFound() para esconder recursos tras un 404 en vez de admitir que existen. Todas documentadas; ninguna necesaria hoy.

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 min

En 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: &lt;script&gt;alert("pwned")&lt;/script&gt;</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.

# demo determinista:
php artisan tinker
> App\Models\Cliente::where('nombre', 'like', '%script%')->count() = 1 > e('<b>hola</b>') // el helper del php_01, vivo y presente = "&lt;b&gt;hola&lt;/b&gt;"

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: binding

Cada 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()

php artisan tinker
> $p = App\Models\Producto::create([ 'nombre' => 'Memoria USB', 'precio' => '25.00', 'stock' => 10, 'total' => '0.01', ]) > $p->offsetExists('total') = false > App\Models\Producto::firstWhere('nombre', 'Memoria USB')->precio = "25.00"

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

AmenazaDefensa Laravel (ya activa)Eco php_01/MVC
XSS{{ }} = e() automáticoe() manual en cada echo
SQLibindings universalesstmt + bind_param a mano
CSRF@csrf + verificación 419 (cap. 12)token en $_SESSION casero
Mass assignment$fillable/$guardedfiltros ad-hoc
Sesión fijadaregenerate() en login (cap. 31)session_regenerate_id
PasswordsHash::make bcrypt cost 12password_hash igual
Cookie de sesiónhttponly + samesite=lax por defaultconfigurar 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.

La trampa restante: el eslabón débil sigue siendo el humano. Validar entrada (cap. 12) NO es opcional «porque ya escapa»: el precio negativo del cap. 26 no era XSS ni SQLi — era negocio roto. Las reglas de la casa siguen mandando; el blindaje solo cubre las balas que no ves.

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 min

El 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

DiscoRaíz físicaPara qué
local (default)storage/app/privatereportes, comprobantes: nadie los descarga por URL
publicstorage/app/publicfotos 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:

php artisan storage:link
The [public/storage] directory has been linked.

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 make:migration add_foto_to_productos_table --table=productos
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>
    @enderror

Del $_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

php artisan tinker
> Storage::disk('public')->exists('productos/teclado.jpg') = true > App\Models\Producto::find(1)->foto = "productos/teclado.jpg"
@if ($producto->foto)
    <img src="{{ asset('storage/'.$producto->foto) }}"
         alt="{{ $producto->nombre }}" class="img-thumbnail">
@endif
Lo privado también se comparte: los comprobantes van al disco local — nadie puede pedirlos por URL aunque adivine el nombre. Si tu frontend debe descargarlos, temporaryUrl('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 min

En 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:

# .env — el driver database usa TU MariaDB:
QUEUE_CONNECTION=database

El job: una clase con contrato

php artisan make:job GenerarReporteVentas
<?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ásicaAtributo 13Semá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

php artisan tinker
> GenerarReporteVentas::dispatch()->onQueue('reportes') = App\Jobs\GenerarReporteVentas {} > DB::table('jobs')->count() = 1 > DB::table('jobs')->value('queue') = "reportes"

dispatch(new GenerarReporteVentas) y GenerarReporteVentas::dispatch() son equivalentes. Sin onQueue() va a la cola default; con él eliges carril.

El worker: el empleado incansable

php artisan queue:work --once
Processing: App\Jobs\GenerarReporteVentas Processed: App\Jobs\GenerarReporteVentas

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:

> Storage::exists('reportes/ventas.json') = true > Storage::json('reportes/ventas.json') // disco default: local => [ "PAGADO" => 3, "ANULADO" => 1, ]

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:failed
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.

Colas y transacciones: si dispatch() corre DENTRO de la caja del cap. 21/cap. 29, el worker puede levantar ANTES del commit y no encontrar el pedido. La propiedad $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 min

En 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 artisan make:event PedidoPagado
<?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 artisan make:listener ActualizarEstadisticas --event=PedidoPagado
<?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:

php artisan event:list | grep -A 2 "PedidoPagado"

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

php artisan tinker
> App\Events\PedidoPagado::dispatch(App\Models\Pedido::find(2)) = null > exit
grep "pagado" storage/logs/laravel.log | tail -1
local.INFO: Pedido 2 pagado. {"id":2}

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.

¿Evento o job directo? Regla de la casa: si hoy hay UNA sola reacción, llama al job directamente (menos piezas). Cuando aparece la segunda reacción, introduce el evento. Y si un listener devuelve false, corta la cadena: los siguientes ya no corren — úsalo como «veto», no como flujo normal.

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 min

Ni 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 artisan make:mail ReciboPedido
<?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)

php artisan tinker
> Mail::to(App\Models\Pedido::find(4)->cliente->email) ... ->send(new App\Mail\ReciboPedido(App\Models\Pedido::find(4))) = null
grep "^Subject:" storage/logs/laravel.log | tail -1
Subject: Recibo del pedido #4

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:

docker run -d --name mailpit -p 1025:1025 -p 8025:8025 axllent/mailpit
# .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 #4
Producción: cambia MAIL_MAILER por tu proveedor real (la doc muestra Cloudflare/Mailgun/roundrobin como ejemplos) y NUNCA uses Mailpit ahí. Para probar sin enviar nada, los tests usan Mail::fake()+assertSent() — cap. 40. Y recuerda: con ShouldQueue, este mailable viaja por la tabla jobs del cap. 35.

Puntos 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 min

En 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:

# terminal 1
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

# crontab -e (servidor):
* * * * * 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.

Patrón recomendado: el scheduler DESPACHA jobs, no los ejecuta. La tarea programada dura milisegundos (encolar y salir) y el worker hace el trabajo lento: una noche saturada no acumula procesos colgados del cron.

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 min

El 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í:

# .env
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ánea

Cache::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.

php artisan tinker
> Cache::remember('demo.productos', 60, fn () => App\Models\Producto::count()) = 32 > Cache::get('demo.productos') // segunda lectura: sin SQL = 32 > Cache::forget('demo.productos') = true > Cache::get('demo.productos') = null

Invalidar donde escribes

// Api\PedidoControlador@update — tras la transición a PAGADO:
$pedido->update(['estado' => 'PAGADO']);
Cache::forget('panel.metricas');   // el dashboard vuelve a computar

La 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

HerramientaQué 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:clearvacía todo el store (dev/despliegues)
No caches lo barato: un COUNT sobre 32 filas tarda menos que leerla de la caché. El criterio es presupuesto de queries bajo carga y coste de cómputo (agregados grandes, PDFs, llamadas externas). Y jamás caches datos POR USUARIO bajo una clave compartida — incluye el id en la clave o no caches.

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 min

En 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
});
php artisan test --filter=ProductoTest
PASS Tests\Feature\ProductoTest ✓ it lists productos activos Tests: 1 passed Duration: 0.42s

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);
});
Detalle fino: si tu controlador usa store() (hashName aleatorio), captura la ruta devuelta por la respuesta y asértala — o cambia a storeAs en tests. Los mismos fakes existen para Queue (assertPushed), Mail (assertSent) y Notification (assertSentTo).

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 min

dd() 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:

# .env
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:

local.INFO: Pedido 2 pasado a PAGADO. {"id":2,"estado":"PAGADO"}
php artisan pail  # tail -f de tu app: logs en vivo en consola

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

HerramientaQué 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
Disciplina de la casa: loggear decisiones de negocio (transición de estados, stock crítico) con contexto; jamás passwords ni tokens (el cap. 28 mostró plainTextToken UNA vez: en logs, nunca). Un error 500 sin entrada en el log es un bug doble: el fallo Y su invisibilidad.

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 min

Cuarenta 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

ComandoGenera enRecuerda
make:model Cliente -mfscapp/Models + database/m=migración f=factory s=seeder c=controlador
make:migration create_x_tabledatabase/migrations--table=x para alter; ya aplicada = intocable
make:controller Web/XControlador --resource --model=Xapp/Http/Controllers--api quita create/edit; subcarpeta con barra
make:request GuardarPedidoapp/Http/RequestsForm Request cuando validate() crece
make:event / make:listener --event=Eapp/Events · app/Listenerslistener se auto-descubre por type-hint
make:job / make:mail / make:commandapp/Jobs · app/Mail · app/Consolejob ligero; mailable envelope+content
make:policy --model / make:resource Xapp/Policies · app/Http/Resourcespolicy por convención Modelo→ModeloPolicy

Base de datos: el ciclo completo

ComandoCuándo usarlo
migrateaplica pendientes; jamás toca aplicadas
migrate:fresh --seedDEV ONLY: borra TODO y reconstruye
migrate:rollback --step=1deshace el último lote aún no compartido
migrate:statusqué corrió y qué falta
migrate --forceproducción: salta el prompt interactivo
db:show / db:seed / model:show Xinspecció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

FamiliaComandos
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               # 503 para todos
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 storage:link / unlink
php artisan tinker --execute="App\Models\Pedido::count();"
php artisan test --parallel  # suite más rápida
El meta-comando: 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 min

Todo 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

# 1. Cerrar con bypass personal:
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
# 5. Cachés del framework en un golpe:
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-data

Dos 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

ÍtemPor qué
APP_DEBUG=false · APP_ENV=productionel stacktrace es mapa para atacantes
APP_KEY intacto entre desplieguesrotarla rompe sesiones y cifrado
Permisos storage/ y bootstrap/cache/logs, vistas compiladas, estado down
Backup ANTES de migrate --forcerollback de datos no existe
Health check /up monitoreado200 = app viva (cap. 1)
LOG_LEVEL=info + LOG_CHANNEL dailyruido controlado, rotación automática
503 propia: resources/views/errors/503.blade.phpmaintenance con cara de tu marca
Lo que NUNCA: migrar con usuarios dentro (por eso el down va PRIMERO), editar .env directo en servidor sin reflejarlo en el repo, correr queue:work como root o bajo screen/tmux, y confiar en que «la caché se regenera sola» — optimize después de subir código, siempre.

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 min

Tercera 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

ParteCapsLo que dominas
I · Del standalone al framework1–7ciclo de vida, artisan, config/.env, helpers, rutas
II · Núcleo HTTP8–13controladores, Blade I/II, middleware, formularios+CSRF, sesiones
III · Base de datos14–19MariaDB, migraciones espejo de tienda_orm, modelos, factories, relaciones
IV · CRUD web20–25binding, transacción con lockForUpdate, scopes, paginate(), dashboard
V · API REST26–30rutas sin estado, Resources JSON, Sanctum, endpoints Pedidos, fetch
VI · Seguridad31–34login artesanal, policies/gates, blindaje XSS/SQLi/CSRF, Storage
VII · Servicios35–39colas database, eventos, mailables, scheduler, caché
VIII · Calidad y producción40–43Pest 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 rutasRoute:: + Route::resource (cap. 8)
include/layout() de plantillas@extends/@yield y componentes x-
e() en cada echo{{ }} escapa siempre
$_SESSION y session_regenerate_idsession()->put/regenerate (caps. 13·31)
PDO preparado a manoEloquent + bindings universales
paginar() artesanalpaginate(10)+withQueryString()
token CSRF casero@csrf / PreventRequestForgery
script cron sueltoSchedule::command()->dailyAt()
nohup para lo lentojobs + queue:work bajo supervisor
backup antes de tocar SQLmigrate --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.
La lección de las tres vueltas: HTML→MVC te dio el oficio, Eloquent la persistencia, Laravel la industria. Los patrones sobrevivieron cada cambio de herramienta — contratos, transacciones, presupuesto de queries, seguridad como diseño. Eso era lo transferible desde el día uno.

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.