Eloquent · el ORM antes que el framework
El ORM más usado de PHP instalado sin framework:
illuminate/database vía Composer sobre MariaDB, con la base
tienda_orm (4 tablas relacionadas, ~200 registros)
para dominar desde all() hasta eager loading,
transacciones y volúmenes grandes.
1 · El costo oculto del SQL suelto
Básico ~15 minEste manual no empieza con Eloquent: empieza con TU código. El repositorio PDO que construimos en el manual MVC era correcto, seguro y bien diseñado… y aun así esconde un costo que nadie factura: cada tabla nueva exige reescribir el mismo andamiaje. Antes de instalar nada, midamos ese precio con precisión.
- Auditar el boilerplate real de un repositorio PDO artesanal.
- Identificar los tres dolores estructurales que se repiten en TODO proyecto.
- Fijar la vara: qué exigiremos a cualquier herramienta que pretenda reemplazarlo.
La autopsia de PdoPedidoRepositorio
<?php
// Lo que escribimos a mano para CADA tabla del manual MVC:
public function encontrar(int $id): Pedido
{
$st = $this->pdo->prepare('SELECT * FROM pedidos WHERE id = :id');
$st->execute(['id' => $id]);
$fila = $st->fetch(PDO::FETCH_ASSOC);
if ($fila === false) {
throw new PedidoNoEncontrado("Pedido $id no existe");
}
return Pedido::desdeFila($fila); // mapeo manual fila -> objeto
}
public function agregar(Pedido $p): int
{
$st = $this->pdo->prepare(
'INSERT INTO pedidos (cliente_id, total, estado, creado)
VALUES (:c, :t, :e, :f)'
);
$st->execute([
'c' => $p->clienteId,
't' => $p->total,
'e' => $p->estado->value,
'f' => $p->creado->format('Y-m-d H:i:s'),
]);
return (int) $this->pdo->lastInsertId();
}Correcto hasta el último carácter. Y ahora la cuenta: ~120 líneas de plomería idéntica por tabla — encontrar, todos, porEstado, porCliente, agregar, actualizar, cambiarEstado, borrar. La tienda tenía 4 tablas: 480 líneas de andamiaje que NO contienen una sola regla del negocio. El cliente pidió «una tabla de productos»: nosotros entregamos medio día de copy-paste disciplinado.
Los tres dolores estructurales
- Mapeo repetido: cada tabla necesita su
desdeFila()y su lista de parámetros de INSERT. Escribirlos es mecánico; olvidar UNO al agregar una columna es el bug clásico que aparece tres semanas después. - Tipado perdido: PDO devuelve strings («17», «540.00», «2026-08-23») aunque la columna sea INT/DECIMAL/DATETIME. El cast vive en tu cabeza y en desdeFila() — la BD lo sabía y lo tiró.
- Dinámicas frágiles: filtrar por cliente SOLO si vino el filtro, ordenar SOLO si pidieron orden… termina en concatenación condicional de SQL donde cada línea nueva es una oportunidad para romper o inyectar.
<?php
// El patron dinamico artesanal: funcional pero frágil
$sql = 'SELECT * FROM pedidos WHERE 1=1';
$params = [];
if ($clienteId !== null) {
$sql .= ' AND cliente_id = :c';
$params['c'] = $clienteId;
}
if ($desde !== null) {
$sql .= ' AND creado >= :d';
$params['d'] = $desde->format('Y-m-d H:i:s');
}
$sql .= match ($orden) {
'total' => ' ORDER BY total DESC',
default => ' ORDER BY creado DESC',
};La vara de medición para este manual
Cada vez que Eloquent resuelva algo, lo compararemos contra ESTE punto de partida. Las exigencias mínimas heredadas del manual MVC:
- Sentencias preparadas SIEMPRE, sin excepciones humanas posibles.
- Transacciones accesibles para pedido+detalles (eco cap. 20).
- El dominio puede seguir siendo readonly fuera de la capa de persistencia.
- Cero SQL concatenado con datos de usuario.
Puntos clave
- ~120 líneas de plomería por tabla ANTES de escribir negocio real.
- Tres dolores: mapeo repetido, tipado perdido, dinámicas frágiles.
- No fue mala praxis: es el precio natural de la frontera objetos↔tablas.
- Vara de medición fijada: preparadas, transacciones, dominio limpio, cero concat.
2 · Qué es exactamente un ORM
Básico ~16 minObject-Relational Mapper: un traductor permanente entre el mundo de objetos PHP (clases, propiedades, relaciones) y el mundo relacional (tablas, filas, joins). Suena simple; la gracia está en DÓNDE vive la traducción. Este capítulo instala el vocabulario que usaremos los 32 capítulos restantes.
- Nombrar el problema de fondo: el mismatch objeto-relacional.
- Distinguir Active Record de Data Mapper — y ubicar Eloquent en el mapa.
- Reconocer a nuestro repositorio PDO como Data Mapper artesanal.
El mismatch: dos mundos con gramáticas distintas
| Mundo objetos (PHP) | Mundo relacional (MariaDB) |
|---|---|
Un Pedido con propiedad $cliente (objeto) | Una fila + columna FK cliente_id (número) |
Colecciones en memoria (array) | Resultados SET, sin orden garantizado |
| Herencia e interfaces | No existe el concepto |
| Identidad = instancia (===) | Identidad = clave primaria |
| Tipos ricos: DateTimeImmutable, enum | DATETIME, VARCHAR planos |
Ese abismo es REAL — lo sufriste escribiendo desdeFila() y format('Y-m-d H:i:s') en el manual MVC. El ORM automatiza exactamente ese cruce de frontera.
Dos escuelas de traducción
<?php
// ACTIVE RECORD: la entidad SABE persistirse (Eloquent)
$cliente = Cliente::find(3); // lee
$cliente->nombre = 'Café Central';
$cliente->save(); // genera UPDATE solo
// DATA MAPPER: la entidad NO sabe nada de BD (Doctrine)
$cliente = $repo->find(3); // el REPOSITORIO hace el trabajo
$cliente->nombre = 'Café Central';
$em->flush(); // el MANAGER decide el SQL| Criterio | Active Record | Data Mapper |
|---|---|---|
| ¿Dónde está el SQL? | Dentro del modelo (heredado) | En repositorios/managers separados |
| Curva inicial | Baja — 10 minutos al primer save() | Alta — mapeos XML/atributos |
| Entidad «limpia» | No: hereda de Model con magia | Sí: POO pura sin dependencias |
| Rapidez CRUD | Imbatible | ceremonial |
| Ejemplos PHP | Eloquent, Yii AR | Doctrine, nuestro repo PDO |
La revelación incómoda y liberadora: tu PdoPedidoRepositorio era un Data Mapper artesanal — entidad limpia por un lado, clase que habla SQL por otro. Eloquent elige el camino contrario: fusión. Ninguno es «el correcto»; son trade-offs distintos.
La promesa concreta del traductor
<?php
// Lo que en el manual MVC nos costó un JOIN manual + hidratación:
$pedido = Pedido::find(17);
echo $pedido->cliente->nombre; // "Café Central" — sin JOIN escrito
echo $pedido->estado->value; // cast automatico a enum
echo $pedido->total; // float real, no string "540.00"Tres líneas donde antes había prepare/fetch/desdeFila. Y cada una de esas propiedades viaja tipada: el mismatch se resuelve UNA vez, dentro del ORM, no en cada proyecto.
Puntos clave
- ORM = traductor permanente del mismatch objeto-relacional.
- Active Record fusiona entidad+persistencia; Data Mapper los separa.
- Eloquent es AR; tu repo PDO artesanal era DM sin saberlo.
- Builder (capa baja) y modelos (capa alta): subiremos por capas.
3 · Ventajas honestas
Básico ~14 minLos evangelios de ORM prometen paraísos; los manuales anti-ORM, infiernos. La verdad está en números verificables. Aquí están las cinco ventajas REALES — cada una demostrada contra el código del manual MVC, no contra un espantapájaros.
- Cuantificar la ganancia CRUD con el mismo caso real a ambos lados.
- Verificar que la seguridad preparada es estructural, no disciplina.
- Reconocer las dinámicas componibles y el mantenimiento barato.
Ventaja 1 · Productividad medible
<?php
// ANTES (manual MVC): 15 lineas por consulta tipica
$st = $pdo->prepare('SELECT * FROM pedidos WHERE cliente_id = :c AND total > :t ORDER BY creado DESC');
$st->execute(['c' => 3, 't' => 100]);
$pedidos = array_map(Pedido::desdeFila(...), $st->fetchAll(PDO::FETCH_ASSOC));
// DESPUES (Query Builder Eloquent): misma consulta, 1 linea
$pedidos = DB::table('pedidos')
->where('cliente_id', 3)
->where('total', '>', 100)
->orderByDesc('creado')
->get();No es solo menos teclear: es MENOS SUPERFICIE PARA FALLAR. Cada línea artesanal era una oportunidad de typo en nombre de parámetro; aquí no existen parámetros nombrados que olvidar — las condiciones viajan como datos.
Ventaja 2 · Seguridad estructural
En PDO crudo, la seguridad dependía de TU DISCIPLINA: cada prepare correcto era una decisión personal repetida miles de veces. Un solo $pdo->query("... $id ...") olvidado y adiós. Con Eloquent los bindings son el ÚNICO camino:
<?php
// No hay forma natural de concatenar: where() SIEMPRE bindea
DB::table('clientes')->where('nombre', $sucioDelUsuario)->get();
// genera: WHERE nombre = ? [parametro vinculado, jamas interpolado]La inyección SQL deja de ser un riesgo de memoria humana para volverse un error de intención explícita (hay que ESFORZARSE para romperla con whereRaw — y ese esfuerzo se ve en code review).
Ventaja 3 · Dinámicas sin ensamblar strings
<?php
// El patron fragil del cap. 1, version builder:
$q = DB::table('pedidos');
if ($clienteId !== null) {
$q->where('cliente_id', $clienteId);
}
if ($desde !== null) {
$q->where('creado', '>=', $desde);
}
$filas = $q->orderBy($ordenValido)->limit(50)->get();Mismo flujo condicional del capítulo 1 — pero sin el WHERE
1=1, sin contadores de parámetros, sin espacios que olvidar antes del AND. El
objeto acumula condiciones; el SQL se compone al final, sintaxis garantizada.
Ventaja 4 · Tipado recuperado
<?php
// Con casts declarados UNA vez (cap. 25 lo formaliza):
$p = Pedido::find(17);
var_dump($p->total); // float(540) — no string "540.00"
var_dump($p->creado); // Carbon/DateTimeImmutable — no "2026-08-23..."
var_dump($p->estado); // enum EstadoPedido — no string sueltoEl trabajo que hacíamos en cada desdeFila() pasa a configuración central: la conversión primitivos→tipos ricos ocurre automáticamente y para TODAS las consultas.
Ventaja 5 · Mantenimiento barato
Agregar columna notas VARCHAR(255) NULL a pedidos:
- Artesanal: tocar INSERT + SELECT implícito + desdeFila + array de params — cuatro lugares coordinados.
- Eloquent: la columna existe; si tu modelo usa inserción masiva, agregar 'notas' a $fillable (una línea). El resto ya funciona.
Puntos clave
- CRUD diario: ~6 líneas artesanales → 1 cadena fluida.
- Bindings estructurales: la seguridad deja de depender de tu memoria.
- Dinámicas acumulables: adiós ensamblaje manual de SQL.
- Tipos ricos automáticos y columnas nuevas casi gratis.
4 · Costos reales (y cuándo NO usarlo)
Intermedio ~15 minUn manual que solo elogia es propaganda. Las ventajas del capítulo anterior son verificables — y también lo son estos cinco costos, cada uno con su escenario de aparición. Conocerlos ANTES de instalar evita los proyectos que terminan odiando al ORM por culpa propia.
- Entender la abstracción con fugas y su consecuencia práctica.
- Ver el N+1 en seis líneas (el clásico que tumba aplicaciones).
- Delimitar los tres escenarios donde PDO crudo sigue siendo la respuesta.
Costo 1 · La abstracción gotea
«Abstracción con fugas» (leaky abstraction): la capa promete que no necesitas SQL… hasta que sí. Window functions, CTEs recursivas, hints de índice, EXPLAIN: tarde o temprano el problema real exige el lenguaje de abajo. Eloquent lo admite (whereRaw, selectRaw, DB::select) pero ahí pagas doble: la sintaxis del builder Y el SQL crudo dentro.
<?php
// Ranking con ventana: el builder NO lo expresa; hay que abrir la valvula
$top = DB::select('
SELECT cliente_id, total,
RANK() OVER (PARTITION BY cliente_id ORDER BY total DESC) AS puesto
FROM pedidos
');Costo 2 · El SQL generado es invisible
Con PDO escribiste el SQL letra por letra — sabías EXACTAMENTE qué iba a MariaDB. Con Eloquent delegas la redacción: la cadena fluida se traduce a SQL en tiempo de ejecución, y a veces no es el SQL que creías. La cura existe y la usaremos mucho:
<?php
$q = DB::table('pedidos')->where('total', '>', 100)->limit(5);
var_dump($q->toSql());
// "select * from `pedidos` where `total` > ? limit 5"Nota las comillas invertidas y el placeholder: detalles del dialecto que antes eras tú quien escribía. Sin toSql/getQueryLog (cap. 30) trabajas a ciegas.
Costo 3 · N+1: la trampa elegante
<?php
// Se VE inocente:
foreach ($pedidos as $pedido) {
echo $pedido->cliente->nombre; // 1 query EXTRA por iteracion!
}
// 50 pedidos = 1 query de lista + 50 queries de cliente = 51 idas a BDEl código es hermoso y la base de datos sangra. Es EL bug de rendimiento número uno en proyectos ORM — le dedicaremos un capítulo completo (20) con medición incluida.
Costo 4 · Bulk simple: PDO crudo gana
Insertar 10 000 filas planas sin relaciones ni eventos: el ORM carga hidratación, eventos y objetos para algo que es puro transporte. INSERT multi-valores directo puede ser 5–10× más rápido. Por eso nuestro capítulo 28 enseña insert() masivo SIN instanciar modelos — saber cuándo bajar de capa es parte del oficio.
Costo 5 · Curva doble
Dominar Eloquent exige conocer DOS sintaxis (builder + modelos) Y el SQL que ambas generan. Quien salta el SQL termina escribiendo consultas terribles «porque el ORM lo permite». Este manual insiste: cada construcción se verifica con toSql — el ORM es atajo, nunca sustituto de entender.
El veredicto preliminar
| Escenario | Herramienta correcta |
|---|---|
| CRUD web cotidiano, formularios, listados | Eloquent — imbatible |
| Reportes analíticos complejos | SQL crudo vía DB::select |
| Cargas masivas planas (>10k filas) | insert() bulk o LOAD DATA |
| Consultas ultra calientes (métricas) | PDO crudo / caché / vista materializada |
| Equipo junior que debe ser productivo YA | Eloquent con revisión de toSql |
Puntos clave
- Toda abstracción gotea: reserva DB::select para SQL serio.
- toSql()/getQueryLog son tus ojos: sin ellos trabajas ciego.
- N+1 rompe aplicaciones bonitas — capítulo 20 entero para vencerlo.
- Bulk masivo y métricas calientes: PDO crudo sigue siendo rey.
5 · El veredicto técnico
Intermedio ~14 minVentajas medidas, costos medidos — hora del fallo. No existe «PDO contra Eloquent»: existe UNA arquitectura donde conviven por jerarquía de problema. Este capítulo cierra la Parte I con la política de uso que seguiremos TODO el manual.
- Fijar la arquitectura híbrida y su regla de decisión.
- Ubicar al viejo repositorio PDO dentro de la nueva era.
- Presentar el laboratorio donde practicaremos todo.
La pirámide de decisión
El error clásico es elegir UNA herramienta por dogma. El profesional cambia de capa según el problema — y sabe bajar de nivel SIN culpa cuando el caso lo exige.
¿Y nuestro repositorio artesanal?
Muere con honor. Su interfaz (PedidoRepositorio) era la idea
valiosa: los controladores dependían del CONTRATO, no de PDO. Con Eloquent, esa interfaz se
implementa internamente con modelos — o desaparece en proyectos pequeños donde el controlador
usa Pedido::find() directamente. Lo que NUNCA debe volver es el copy-paste de
hidratación manual.
<?php
// El mismo contrato del manual MVC, nueva implementacion interna:
final class EloPedidoRepositorio implements PedidoRepositorio
{
public function encontrar(int $id): Pedido
{
$m = PedidoModel::findOrFail($id); // 404 automatico si falta
return new Pedido(
id: $m->id,
clienteId: $m->cliente_id,
total: $m->total,
estado: EstadoPedido::from($m->estado),
creado: new DateTimeImmutable($m->created_at),
);
}
}Esa frontera (modelo Active Record adentro, entidad readonly afuera) es exactamente cómo los equipos serios combinan ambas filosofías del capítulo 2.
Qué construimos desde aquí
- Proyecto standalone: carpeta con composer.json, boot.php y scripts de práctica — sin servidor web, todo corre por consola.
- Base tienda_orm: 4 tablas relacionadas del dominio Pedidos que ya conoces (clientes, productos, pedidos, pedido_detalles).
- ~200 registros deterministas: mismos datos para todos, mismos resultados en cada ejemplo — reproducibilidad de laboratorio.
Puntos clave
- Híbrido por pirámide: builder abajo, modelos al centro, SQL crudo arriba.
- El contrato del repo sobrevive; la plomería manual no.
- Laboratorio standalone + tienda_orm determinista = práctica reproducible.
- Standalone primero: separar el ORM de la magia del framework.
6 · Composer en diez minutos
Básico ~15 minComposer es el gestor de dependencias de PHP — el npm del ecosistema, si vienes de JavaScript; el cargo, si de Rust. Eloquent NO vive en tu instalación de PHP: es un paquete que Composer trae, versiona y autocarga. Diez minutos aquí evitan horas de misterio después.
- Instalar/verificar Composer en tu sistema.
- Dominar los cuatro comandos que usarás el 95% del tiempo.
- Entender composer.json, composer.lock y la carpeta vendor.
Instalación (o verificación)
En Windows: instalador oficial desde getcomposer.org (detecta tu PHP). Si ya usaste Composer para el manual MVC, tienes todo.
El proyecto laboratorio
Los cuatro comandos sagrados
Anatomía del proyecto
tienda_orm_lab/
├── composer.json <-- DECLARAS lo que necesitas (a mano)
├── composer.lock <-- versiones EXACTAS resueltas (automatico)
├── vendor/
│ ├── autoload.php <-- el autoloader magico (1 require y listo)
│ └── illuminate/ <-- el codigo de Eloquent vive aqui
└── boot.php <-- nuestro arranque (cap. 7)- composer.json: intenciones — «quiero illuminate/database».
Editable a mano o vía
require. - composer.lock: realidad — versión exacta descargada. SE COMITEA al repo para que todos (y producción) tengan idéntico entorno. El eco exacto del config.local.php del manual MVC: cosas distintas viven en lugares distintos.
- vendor/: NUNCA se comitea. Se reconstruye con
composer installdesde el lock.
La magia del autoload
<?php
// Un solo require habilita TODAS las clases de TODAS las dependencias:
require __DIR__ . '/vendor/autoload.php';
// Desde aqui existen Illuminate\Database\Capsule\Manager y familia...
// ...y las que TU declares con PSR-4 en composer.json:{
"autoload": {
"psr-4": { "App\\": "src/" }
}
}dump-autoload. Síntoma: «Class App\Pedido not found» aunque el archivo
exista. El autoloader no adivina: lee el mapa generado.Puntos clave
- require / install / update / show --tree: los 4 comandos cotidianos.
- json declara, lock fija, vendor contiene — y vendor nunca al repo.
- vendor/autoload.php = un require para gobernarlos a todos.
- PSR-4 + dump-autoload para tus propias clases bajo App\.
7 · Capsule: Eloquent sin Laravel
Intermedio ~16 minEl paquete instalado trae el motor — falta encenderlo. La clase
Capsule\Manager es el arranque oficial para usar illuminate/database
FUERA del framework: registra la conexión, habilita el builder y despierta a los
modelos. Doce líneas que sustituyen al service provider de Laravel.
- Instalar los paquetes exactos y entender por qué events.
- Escribir boot.php completo y probar la conexión.
- Comprender qué hace cada llamada (setAsGlobal vs bootEloquent).
Los dos paquetes necesarios
¿Por qué events? Los modelos Eloquent disparan eventos (creating, updating, deleted…). Sin el paquete de eventos los modelos funcionan para consultas pero explotarán al primer save() con listener registrado. Instalamos desde ya: cero sorpresas.
boot.php, el front controller de la BD
<?php
// boot.php — requerido por TODOS nuestros scripts de practica
require __DIR__ . '/vendor/autoload.php';
use Illuminate\Database\Capsule\Manager as Capsule;
$capsule = new Capsule;
$capsule->addConnection([
'driver' => 'mysql', // MariaDB habla protocolo mysql
'host' => '127.0.0.1',
'port' => 3306,
'database' => 'tienda_orm',
'username' => 'tienda',
'password' => 'secreto_dev',
'charset' => 'utf8mb4', // tildes, eñes y emojis seguros
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '', // sin prefijos de tabla
]);
// PDO subyacente en modo estricto: como nuestro manual MVC
$capsule->setFetchMode(\PDO::FETCH_ASSOC);
$capsule->setAsGlobal(); // DB::table() disponible EN CUALQUIER lado
$capsule->bootEloquent(); // modelos Illuminate\Database\Eloquent listosLas tres llamadas, una por una
addConnection(): guarda la configuración (aún no conecta). MariaDB usa driver 'mysql' porque implementa su protocolo y dialecto SQL base — mismo camino que siguió PDO en el manual MVC con sqlsrv/pgsql.setAsGlobal(): publica la instancia estática — permiteCapsule::table()yDB::table()sin pasar objetos por ahí. Conveniente para scripts; en apps serias se inyecta como servicio.bootEloquent(): conecta el Event Dispatcher y registra el Eloquent Service Provider — la pieza que hace vivos a los modelos.
Prueba de pulso
<?php
// pulso.php
require __DIR__ . '/boot.php';
use Illuminate\Database\Capsule\Manager as Capsule;
$version = Capsule::select('SELECT VERSION() AS v')[0]->v;
echo "Conectado a MariaDB $version\n";
echo "Tablas: ";
print_r(Capsule::select('SHOW TABLES'));(Si aún no creaste la base, el SHOW TABLES vendrá vacío o fallará — normal: el capítulo siguiente construye tienda_orm completa.)
Credenciales: eco directo del manual MVC
<?php
// Nunca hardcodeadas: misma leccion del cap. 30 del manual MVC
$config = require __DIR__ . '/config.local.php'; // gitignored
$capsule->addConnection($config['bd']);Puntos clave
illuminate/database+illuminate/events: par mínimo.- boot.php = addConnection + setAsGlobal + bootEloquent (12 líneas).
- MariaDB viaja bajo driver 'mysql': protocolo compartido.
- utf8mb4 siempre; credenciales fuera del código y del repo.
8 · La base tienda_orm
Intermedio ~16 minLlega el script descargable: php_03_tienda_orm.sql. No es un volcado
cualquiera — cada decisión de esquema está tomada para que Eloquent trabaje SIN
configuración extra y para que las lecciones del manual MVC (dinero DECIMAL,
borrado defensivo, estados cerrados) queden clavadas en la propia base.
- Ejecutar el script y entender tabla por tabla.
- Reconocer las convenciones que Eloquent espera por defecto.
- Justificar tipos y restricciones con lo ya aprendido.
Ejecución
Las cuatro tablas
-- 1) CLIENTES — el maestro mas simple
CREATE TABLE clientes (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
email VARCHAR(150) NOT NULL UNIQUE, -- unicidad en BD, no solo en app
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL
);
-- 2) PRODUCTOS — catalogo de la tienda
CREATE TABLE productos (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(120) NOT NULL,
precio DECIMAL(10,2) NOT NULL, -- NUNCA FLOAT para dinero
stock INT UNSIGNED NOT NULL DEFAULT 0,
activo TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL
);
-- 3) PEDIDOS — cabecera, eco directo del manual MVC
CREATE TABLE pedidos (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
cliente_id BIGINT UNSIGNED NOT NULL,
total DECIMAL(10,2) NOT NULL DEFAULT 0,
estado ENUM('REGISTRADO','PAGADO','ANULADO') NOT NULL DEFAULT 'REGISTRADO',
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
CONSTRAINT fk_pedido_cliente FOREIGN KEY (cliente_id)
REFERENCES clientes(id) ON DELETE RESTRICT
);
-- 4) DETALLES — las lineas del pedido (relacion N:M resuelta)
CREATE TABLE pedido_detalles (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
pedido_id BIGINT UNSIGNED NOT NULL,
producto_id BIGINT UNSIGNED NOT NULL,
cantidad INT UNSIGNED NOT NULL,
precio_unitario DECIMAL(10,2) NOT NULL, -- congela el precio historicoo
CONSTRAINT fk_detalle_pedido FOREIGN KEY (pedido_id)
REFERENCES pedidos(id) ON DELETE CASCADE,
CONSTRAINT fk_detalle_producto FOREIGN KEY (producto_id)
REFERENCES productos(id) ON DELETE RESTRICT
);Las convenciones que Eloquent hereda gratis
| Convención | Quién la usa |
|---|---|
PK autoincremental llamada id | find(17), save(), lastInsertId interno |
Tablas plural snake_case (pedido_detalles) | Modelo PedidoDetalle → tabla adivinada sola |
FK cliente_id, producto_id | belongsTo infiere la columna del método |
created_at/updated_at | timestamps automáticos al insert/update |
Cada convención seguida es una línea de configuración que NO escribirás. El manual te enseñará también a romperlas cuando heredes bases viejas ($table, $primaryKey, $timestamps = false) — pero el estándar es este.
Decisiones heredadas de los manuales anteriores
- DECIMAL(10,2) para dinero: FLOAT redondea mal — lección del capítulo 21 de MVC aplicada al esquema mismo.
- ENUM de estados: REGISTRADO/PAGADO/ANULADO como valores cerrados en BD — espejo del enum EstadoPedido de PHP. Dos validaciones, cero estados inventados.
- RESTRICT vs CASCADE: borrar cliente con pedidos → rechazado por la BD (eco del borrado defensivo del CRUD de clientes). Borrar pedido SÍ arrastra sus detalles: son parte integral, sin vida propia.
- precio_unitario en detalles: el precio se CONGELA en la venta; cambiar el catálogo mañana no reescribe la historia.
Puntos clave
- mariadb -u root -p < php_03_tienda_orm.sql: laboratorio listo en 5 segundos.
- Convenciones Laravel (id, plural snake_case, FKs, timestamps) = config cero.
- DECIMAL + ENUM + RESTRICT: las lecciones MVC ahora viven en el esquema.
- precio_unitario congelado: la historia comercial es intocable.
9 · Los datos deterministas
Intermedio ~15 minEl script que ejecutaste contiene una técnica que vale más que las 220 filas que produce: generar datos de prueba REPRODUCIBLES sin fábricas ni librerías externas. Este capítulo abre el capó del generador — porque leerlo bien te servirá para poblar cualquier sistema que pruebes de ahora en adelante.
- Dominar la CTE recursiva como generador de secuencias.
- Fabricar pseudoaleatoriedad DETERMINISTA con módulos.
- Verificar el laboratorio con conteos esperados.
El generador de secuencias
WITH RECURSIVE seq(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM seq WHERE n < 30
)
SELECT n FROM seq;
-- produce 1, 2, 3 ... 30: un "for loop" en SQL puroTres piezas: la semilla (SELECT 1), el paso (n + 1) y el freno
(WHERE n < 30). Es el equivalente SQL de range() — y desde
MariaDB 10.2 es estándar, sin procedimientos almacenados.
Aleatorio… pero siempre igual
-- Asignacion de cliente "pseudoaleatoria" pero determinista:
SELECT ((n * 7) MOD 30) + 1 AS cliente_id FROM seq;El truco: multiplicadores fijos + módulo. No hay RAND() (que cambiaría cada ejecución). Los mismos números de entrada producen los mismos pedidos EN CUALQUIER MÁQUINA — por eso los ejemplos de los próximos capítulos mostrarán los mismos ids que verás tú:
| n | (n*7) MOD 30 + 1 | Estado (n-1 MOD 5) |
|---|---|---|
| 1 | 8 | PAGADO |
| 2 | 15 | PAGADO |
| 3 | 22 | REGISTRADO |
| 5 | 6 | ANULADO |
Catálogo con ELT: nombres sin tipear 40 líneas
CONCAT(
ELT(((n - 1) DIV 8) + 1, 'Cafe', 'Te', 'Chocolate', 'Miel', 'Accesorio'),
' ',
((n - 1) MOD 8) + 1
)
-- n=1..8 -> Cafe 1..8
-- n=9..16 -> Te 1..8 (5 categorias x 8 = 40 productos)ELT(k, a, b, c...) devuelve el k-ésimo elemento de la lista; el DIV reparte
bloques y el MOD cicla dentro de cada bloque. Combinación de dos minutos para fabricar
catálogos creíbles.
El remate: totales recalculados
-- La regla del manual MVC clavada en el seed mismo:
UPDATE pedidos p
SET p.total = COALESCE((
SELECT SUM(d.cantidad * d.precio_unitario)
FROM pedido_detalles d
WHERE d.pedido_id = p.id
), 0);Ningún total se escribió a mano: TODOS derivan de sus detalles. Cuando en el capítulo 27 consultes pedidos, los importes serán matemáticamente coherentes — el laboratorio respeta las mismas leyes que exigimos al sistema real.
Verificación del laboratorio
mariadb -u root -p < php_03_tienda_orm.sql) y verifica que los conteos y
los primeros ids son IDÉNTICOS. Esa reproducibilidad es tu red de seguridad durante
todo el manual: si un ejemplo te da otros números, algo se tocó.Puntos clave
- CTE recursiva = range() de SQL: semilla, paso, freno.
- Multiplicador fijo + MOD = azar determinista, resultados replicables.
- ELT + DIV/MOD fabrican catálogos completos sin tipear filas.
- Totales derivados de detalles: coherencia comercial garantizada.
10 · Tu primera consulta
Básico ~14 minLaboratorio montado, base poblada, Capsule encendida — el momento de la primera consulta. Una sola línea para leer toda una tabla, y con ella tres lecciones sobre lo que el Query Builder te devuelve realmente.
- Ejecutar la primera lectura completa y filtrada.
- Entender QUÉ tipo de dato devuelve el builder (y por qué).
- Adoptar el hábito dd()/dump para inspeccionar resultados.
Hola, mundo relacional
<?php
// primera.php
require __DIR__ . '/boot.php';
use Illuminate\Database\Capsule\Manager as DB;
$clientes = DB::table('clientes')->get();
echo "Total: ", count($clientes), "\n";
print_r($clientes[0]);¿Qué es exactamente ese resultado?
get() devuelve una Colección (la clase Collection de
illuminate, primo del arreglo con superpoderes) cuyos elementos son filas. Como nuestro
boot.php configuró FETCH_ASSOC, cada fila llega como arreglo
asociativo — igual que PDO::FETCH_ASSOC en el manual MVC:
<?php
echo $clientes[0]['nombre']; // Cliente 001 (arreglo: corchetes)
// Nota: Laravel sin configuracion entrega stdClass ($fila->nombre).
// Con modelos Eloquent (cap. 14) SIEMPRE seran objetos tipados,
// sin importar el fetch mode. Builder = arreglos; Modelos = objetos.El primer filtro
<?php
$activos = DB::table('productos')
->where('activo', 1)
->orderBy('precio', 'desc')
->limit(5)
->get();
foreach ($activos as $p) {
printf("%-15s S/ %s\n", $p['nombre'], $p['precio']);
}Léelo en voz alta: «de productos, DONDE activo=1, ORDENA por precio descendente, LÍMITE 5, TRAER». El método se lee en el mismo orden en que piensas la pregunta. Y debajo, el SQL generado tiene bindings estructurales — la seguridad del capítulo 3 sin esfuerzo.
Inspección: dump() y dd()
<?php
DB::table('clientes')->limit(2)->get();
dd(DB::table('clientes')->limit(2)->get()); // dump & die
// dump(): imprime bonito y SIGUE ejecutando
// dd(): imprime bonito y DETIENE el script ahi mismoSerán tus dos mejores amigos durante todo el manual: ante cualquier duda, envuelve la
consulta en dd(...) y mira su contenido crudo.
El placeholder ? confirma los bindings; las comillas invertidas son el
dialecto MariaDB. Mirar toSql() cada tanto mantiene tu SQL vivo bajo la capa fluida.
->count(), ->exists(),
->value('columna'). Los usaremos sin ceremonia desde ya — detalle
completo en agregados (cap. 13).Puntos clave
DB::table()->get(): Colección de filas legible como prosa.- Builder + FETCH_ASSOC = arreglos; Modelos = objetos SIEMPRE (cap. 14).
- dd()/dump() para inspección inmediata; toSql() para ver el SQL real.
- Filtros/orden/límite encadenan en el orden natural del pensamiento.
11 · Builder: SELECT avanzado
Intermedio ~16 minUna consulta real rara vez es «traer todo»: filtra por varios criterios a la vez, excluye conjuntos, busca nulos, pagina. El builder tiene un método para cada intención — este capítulo recorre la familia where completa y los atajos de lectura que usarás todos los días.
- Combinar condiciones AND/OR sin ambigüedad.
- Usar la familia whereIn/whereBetween/whereNull.
- Seleccionar columnas, paginar y leer una sola cosa (pluck/value).
AND y OR con claridad
<?php
// AND implicito: cada where() encadena con AND
$caros = DB::table('productos')
->where('activo', 1)
->where('precio', '>=', 50)
->get();
// OR explicito + agrupacion: parentesis logicos garantizados
$oferta = DB::table('productos')
->where(function ($q) {
$q->where('precio', '<', 10)
->orWhere('stock', '>', 50);
})
->where('activo', 1) // queda FUERA del grupo: AND ((...) OR (...))
->get();La clausura function ($q) agrupa condiciones entre paréntesis — el detalle
que en SQL manual provoca bugs sutiles cuando OR se mezcla con AND sin paréntesis. El
builder los coloca SIEMPRE donde corresponde.
La familia where
<?php
DB::table('pedidos')->whereIn('estado', ['PAGADO', 'ANULADO'])->get();
DB::table('pedidos')->whereBetween('total', [100, 300])->get();
DB::table('clientes')->whereNotNull('email')->count();
DB::table('productos')->where('nombre', 'like', 'Cafe%')->get();| Método | SQL generado | Cuándo usarlo |
|---|---|---|
whereIn(col, [...]) | col IN (?, ?, ?) | conjuntos cerrados (estados, ids) |
whereBetween(col, [a,b]) | col BETWEEN ? AND ? | rangos numéricos/fechas |
whereNull / whereNotNull | col IS NULL | campos opcionales |
where(col, 'like', pat) | col LIKE ? | búsqueda por prefijo/sufijo |
Traer menos: columnas y atajos
<?php
// Solo las columnas necesarias (menos memoria, mas rapido):
$livianos = DB::table('productos')
->select('nombre', 'precio')
->limit(3)
->get();
// pluck: UNA columna como lista plana id=>valor
$nombres = DB::table('clientes')->pluck('nombre', 'id');
// Collection { 1: "Cliente 001", 2: "Cliente 002", ... }
// value: el primer valor de UNA celda
$precio = DB::table('productos')->where('id', 7)->value('precio');
// exists: pregunta si-algo, sin traer nada
$hayAnulados = DB::table('pedidos')->where('estado', 'ANULADO')->exists();El eco directo del manual MVC: fetchColumn() y arreglos manuales quedan
jubilados — cada intención tiene su método nombrado.
Paginación artesanal
<?php
$porPagina = 10;
$pagina = 2;
$paginaDatos = DB::table('pedidos')
->orderByDesc('created_at')
->skip(($pagina - 1) * $porPagina) // OFFSET
->take($porPagina) // LIMIT
->get();
$total = DB::table('pedidos')->count(); // para calcular paginas totalesTres placeholders para dos estados… no: DOS placeholders, exactamente los elementos del arreglo. Los bindings se generan solos y en orden — imposible desincronizarlos.
->where('id', '>', $ultimoId)->limit(20)). Lo retomamos con
chunk()/lazy() en el capítulo 29.Puntos clave
- Cláusulas de cierre = paréntesis lógicos automáticos para OR.
- Familia whereIn/Between/Null/Like cubre el 95% de filtros reales.
- select/pluck/value/exists: traer SOLO lo que la pregunta necesita.
- Skip/take para páginas simples; cursores para profundidades grandes.
12 · Builder: escribir datos
Intermedio ~15 minLeer es la mitad del idioma. Insertar, actualizar y borrar con el builder cierra el CRUD de capa baja — y trae tres sorpresas agradables: el id nuevo gratis, la escritura por lotes nativa y el conteo de filas afectadas como valor de retorno directo.
- Insertar una y muchas filas, capturando el id generado.
- Actualizar con condiciones seguras (y entender el riesgo del update desnudo).
- Borrar con criterio — y conocer truncate como herramienta de laboratorio.
INSERT: uno y muchos
<?php
// Uno:
$nuevoId = DB::table('clientes')->insertGetId([
'nombre' => 'Café Central SAC',
'email' => 'central@cafe.pe',
'created_at' => now(),
'updated_at' => now(),
]);
echo "Cliente creado con id $nuevoId\n";<?php
// Muchos en UNA sola ida (INSERT multi-valores):
DB::table('productos')->insert([
['nombre' => 'Cataji 250g', 'precio' => 18.90, 'stock' => 30,
'created_at' => now(), 'updated_at' => now()],
['nombre' => 'Prensa francesa', 'precio' => 79.00, 'stock' => 8,
'created_at' => now(), 'updated_at' => now()],
]);insertGetId() es el heredero directo del par
execute() + lastInsertId() del manual MVC — una llamada en vez de dos. Y el
insert por lotes genera un solo INSERT multi-fila: el mismo truco que en el capítulo 28 del
manual anterior escribimos a mano.
UPDATE: siempre con where
<?php
$afectadas = DB::table('productos')
->where('id', 7)
->update([
'stock' => DB::raw('stock - 1'), // atomico: descuenta en servidor
'updated_at' => now(),
]);
echo "$afectadas fila(s) actualizada(s)\n";Dos joyas aquí:
- El retorno es el número de filas afectadas — sin rowCount() manual.
- DB::raw('stock - 1') delega la resta al servidor: si dos procesos venden a la vez, ninguno pisa al otro (el clásico leer-modificar-escribir que corrompe inventarios). Es atómico porque MariaDB hace la resta, no PHP.
DELETE y TRUNCATE
<?php
$bajas = DB::table('pedidos')
->where('estado', 'ANULADO')
->where('created_at', '<', '2025-01-01')
->delete(); // borrado condicional seguro
// Vaciar TODO (laboratorio unicamente): reinicia el AUTO_INCREMENT
DB::table('pedido_detalles')->truncate();Fíjate en el orden del ejemplo: borrar pedidos anulados VIEJOS dispara CASCADE sobre sus detalles (la FK que definimos en el capítulo 8 trabajando sola). La integridad ya no depende de recordar borrar hijos primero.
El ciclo completo, verificado
Insertar → actualizar → leer → borrar: el ciclo CRUD entero en cuatro llamadas fluidas. La capa baja está completa — falta darle ALMA a esas filas: los modelos llegan en el capítulo 14.
Puntos clave
insertGetId(): execute + lastInsertId fusionados.- insert por lote = un solo INSERT multi-valores.
DB::raw('col - 1'): aritmética atómica en servidor.- update/delete SIN where afecta todo: where SIEMPRE, sin excepciones.
13 · Joins y agregados
Intermedio ~16 minAquí termina la infancia del builder: consultas que CRUZAN tablas y RESUMEN miles de filas en cifras gerenciales. Es exactamente el músculo que alimentó el reporte CSV del capítulo 28 del manual MVC — ahora con una sintaxis que se lee como la pregunta.
- Cruzar tablas con join() y leftJoin() calificando columnas.
- Resumir con count/sum/avg/max + groupBy y having.
- Construir el reporte «top clientes» completo de punta a punta.
INNER JOIN: solo lo que cruza
<?php
$recientes = DB::table('pedidos')
->join('clientes', 'clientes.id', '=', 'pedidos.cliente_id')
->select('pedidos.id', 'clientes.nombre', 'pedidos.total')
->orderByDesc('pedidos.created_at')
->limit(4)
->get();
foreach ($recientes as $fila) {
printf("#%d %-12s S/ %s\n", $fila->id, $fila->nombre, $fila->total);
}Los argumentos del join se leen solos: «une clientes donde clientes.id = pedidos. cliente_id». Las columnas van CALIFICADAS (tabla.columna) — sin calificar, dos id chocan y el builder no adivina cuál querías.
Agregados: preguntar cifras, no filas
<?php
$stats = [
'facturado' => DB::table('pedidos')->where('estado', 'PAGADO')->sum('total'),
'ticket_max'=> DB::table('pedidos')->max('total'),
'promedio' => DB::table('pedidos')->where('estado', 'PAGADO')->avg('total'),
'lineas' => DB::table('pedido_detalles')->count(),
];
printf("Facturado S/ %s | Max S/ %s | Prom S/ %s | %d lineas\n",
$stats['facturado'], $stats['ticket_max'],
number_format($stats['promedio'], 2), $stats['lineas']);Cada método devuelve EL NÚMERO directamente — ni Colecciones ni arreglos intermedios. Son los herederos directos de las consultas COUNT/SUM a mano del manual anterior.
GROUP BY + HAVING: resumen con filtro posterior
<?php
// Top 5 clientes por importe acumulado:
$top = DB::table('pedidos')
->join('clientes', 'clientes.id', '=', 'pedidos.cliente_id')
->select(
'clientes.id',
'clientes.nombre',
DB::raw('COUNT(*) AS n_pedidos'),
DB::raw('SUM(pedidos.total) AS gastado')
)
->groupBy('clientes.id', 'clientes.nombre')
->havingRaw('SUM(pedidos.total) > ?', [200])
->orderByDesc('gastado')
->limit(5)
->get();DB::raw() inserta SQL literal donde la fluidez no llega (expresiones,
alias). Y havingRaw(... ?) mantiene el binding para el valor — raw para la
EXPRESIÓN, placeholder para los DATOS. La frontera de seguridad intacta.
LEFT JOIN: incluir a los que no compran
<?php
// Clientes SIN ningun pedido (el INNER los habria excluido):
$inactivos = DB::table('clientes')
->leftjoin('pedidos', 'pedidos.cliente_id', '=', 'clientes.id')
->select('clientes.id', 'clientes.nombre')
->whereNull('pedidos.id') // no hubo cruce: nunca compro
->get();
echo count($inactivos), " clientes frios\n";Ese patrón whereNull sobre la tabla derecha es LA receta canónica de «registros huérfanos»
— campañas de reactivación, limpieza de datos, auditorías. En SQL manual escribíamos
... IS NULL tras el LEFT JOIN; aquí, dos métodos.
Método por método, el SQL aparece espejado — si alguna vez dudas qué estás pidiendo, toSql() nunca miente.
Puntos clave
- join()/leftJoin() con tres argumentos legibles; columnas SIEMPRE calificadas.
- sum/avg/max/min/count devuelven escalares listos para usar.
- DB::raw() para expresiones; bindings dentro de havingRaw para datos.
- LEFT JOIN + whereNull = detector canónico de registros sin cruce.
14 · Tu primer modelo
Intermedio ~15 minEl builder habla de TABLAS; los modelos hablan de ENTIDADES. Hoy nace la clase que hace todo el trabajo sucio por ti: una clase Cliente sin un solo método escrito, que ya sabe leerse, escribirse y sincronizarse con MariaDB. El patrón Active Record, destapado.
- Definir el primer modelo y entender su configuración cero.
- Leer entidades (find, all, where) y recibirlas como OBJETOS.
- Descubrir lo que Active Record resuelve gratis (timestamps, fechas Carbon).
Un modelo en tres líneas
<?php
// modelos.php
require __DIR__ . '/boot.php';
use Illuminate\Database\Eloquent\Model;
class Cliente extends Model {} // eso. TODO eso.Sin constructor, sin propiedades, sin SQL. Eloquent deduce TODO por convención:
| Deducción automática | Regla | Aquí resulta |
|---|---|---|
| Tabla | nombre de clase en plural snake_case | clientes |
| Llave primaria | id entero autoincremental | id |
| Timestamps | mantener created_at/updated_at | activos |
Nuestra base del capítulo 8 fue diseñada EXACTAMENTE bajo esas convenciones — por eso no
hay ni una línea de configuración. La deuda se paga al revés cuando heredas bases legadas
(con tabla t_cliente_maestro): ahí sí se declara
protected $table = '...';. Convención sobre configuración —
pero configuración disponible.
La primera lectura como entidad
<?php
$cliente = Cliente::find(1);
echo get_class($cliente), "\n";
echo $cliente->nombre, " <", $cliente->email, ">\n";
var_dump($cliente->id);find() busca por llave primaria y devuelve UN objeto Cliente — o null.
Los atributos se acceden como propiedades mágicas: $cliente->nombre no es
un campo declarado; es la fila de MariaDB proyectada sobre el objeto.
La promesa cumplida: objetos siempre
<?php
$todos = Cliente::where('id', '<=', 3)->get();
var_dump(get_class($todos)); // Collection
var_dump(get_class($todos[0])); // Cliente
foreach ($todos as $c) {
echo $c->id, ': ', $c->email, "\n";
}Recuerda la nota del capítulo 10 («builder = arreglos, modelos = objetos»): aquí se cobra. Con modelos NO importa tu fetch mode — cada fila se hidrata como instancia de SU clase. Métodos de dominio, autocompletado del IDE y type hints quedan disponibles.
Lo que llega gratis #1: fechas Carbon
<?php
$c = Cliente::find(1);
var_dump(get_class($c->created_at));
echo $c->created_at->format('d/m/Y H:i'), "\n";
echo $c->created_at->diffForHumans(), "\n"; // "hace X..." en humanoLas columnas timestamp se convierten solas en objetos Carbon — el envoltorio de fechas de PHP con formatos, comparaciones y diferencias humanizadas. Se acabó el strtotime artesanal.
Lo que llega gratis #2: toArray/toJSON
<?php
print_r(Cliente::find(2)->toArray());
echo json_encode(Cliente::find(3), JSON_PRETTY_PRINT);Colecciones enteras también: Cliente::all()->toJson().
Puerta directa para APIs JSON — la usaremos en el capítulo 33.
Puntos clave
extends Model {}: tabla/PK/timestamps deducidos por convención.- find() devuelve el OBJETO o null; get() una Collection de objetos.
- Fechas llegan como Carbon: format(), diffForHumans() listos.
- Entidad viva = modelo; operación masiva/reporte = builder.
15 · Crear y leer con modelos
Básico ~15 minEl primer create() con modelos explota SIEMPRE. No es un bug: es la característica de seguridad más pedagógica de Eloquent. Este capítulo provoca el error a propósito, lo entiende y lo doma — y de paso cubre las lecturas con manejo de «no existe».
- Sufrir y comprender MassAssignmentException.
- Declarar $fillable como lista blanca (eco directo del validador MVC).
- Crear con create()/save() y leer con findOrFail/firstOrFail.
El rito de iniciación: el primer crash
<?php
// crear.php
require __DIR__ . '/boot.php';
class Cliente extends Illuminate\Database\Eloquent\Model {}
$cliente = Cliente::create([
'nombre' => 'Distribuidora Sur SAC',
'email' => 'sur@dist.pe',
]);Protección contra asignación masiva: si create() aceptara cualquier arreglo, un atacante que inyecte un campo extra en tu formulario («rol=admin») escribiría columnas que jamás quisiste exponer. Eloquent exige una LISTA BLANCA explícita de campos creables desde afuera. Suena familiar? Es la misma filosofía del validador del manual MVC: solo pasan los campos que TÚ autorizaste.
La cura: $fillable
<?php
use Illuminate\Database\Eloquent\Model;
class Cliente extends Model
{
protected $fillable = ['nombre', 'email']; // lista blanca explicita
}Tres notas finas:
- created_at/updated_at NO van en $fillable — Eloquent los estampa solo.
- $fillable define qué puede llegar de FUERA (formularios); no limita asignaciones internas ($c->campo = ... siempre funciona).
latest('id'): ordena descendente por esa columna — atajo de lectura.
La ruta larga equivalente: new + save
<?php
$c = new Cliente();
$c->nombre = 'Café Central SAC';
$c->email = 'central@cafe.pe';
$c->save(); // INSERT aqui mismo
echo "nuevo id: ", $c->id, "\n"; // la PK llega al objeto automaticamenteEsta forma ignora $fillable (las propiedades se asignan una a una, a plena vista) y te
deja el id en $c->id sin llamar lastInsertId(). create([...])
es exactamente esto, comprimido — por eso exige la lista blanca: su arreglo viene «de afuera».
Leer sabiendo que puede faltar
<?php
// find(): null silencioso — tu decides que hacer
$quizas = Cliente::find(999);
var_dump($quizas); // NULL
// findOrFail(): excepcion ModelNotFoundException — ideal para rutas 404
try {
Cliente::findOrFail(999);
} catch (Illuminate\Database\Eloquent\ModelNotFoundException $e) {
echo "cliente inexistente: responder 404\n";
}
// firstOrFail(): igual, para consultas filtradas
$porMail = Cliente::where('email', 'central@cafe.pe')->firstOrFail();
echo $porMail->nombre, "\n";En Laravel, findOrFail dentro de una ruta dispara el 404 automáticamente; en nuestro standalone, la excepción es tu señal para construir la respuesta. El eco del manual MVC es directo: find() era el SELECT que devolvía arreglo vacío y tú decidías el 404 — misma decisión, ahora empaquetada en dos métodos nombrados.
Insertaste sin mencionar fechas ni una vez — y created_at quedó sellado. Active Record trabajando mientras duermes.
Puntos clave
- MassAssignmentException es una FEATURE: lista blanca obligatoria.
- $fillable = campos creables desde fuera; timestamps van aparte.
- new + save() asigna la PK al objeto; create() es su versión comprimida.
- findOrFail/firstOrFail convierten «no existe» en excepción manejable.
16 · Actualizar y borrar con modelos
Intermedio ~14 minLa joya silenciosa de Active Record es que el objeto SABE qué cambió. Modifica dos atributos y save() genera un UPDATE de esas dos columnas — ni una más. Este capítulo explora esa contabilidad interna y cierra la Parte III con la decisión definitiva: ¿modelo o builder?
- Aprovechar el seguimiento de cambios (dirty tracking) en updates mínimos.
- Borrar por instancia, por pk múltiple — y ver las FK trabajando.
- Fijar la regla casa: modelo para entidades, builder para volumen.
Dirty tracking: solo lo que cambió
<?php
// editar.php
require __DIR__ . '/boot.php';
class Producto extends Model {}
$p = Producto::find(7);
echo "precio actual: ", $p->precio, "\n";
$p->stock = 42;
$p->nombre = 'Cataji especial 250g';
var_dump($p->isDirty()); // hay cambios SIN guardar?
$p->save(); // UPDATE ... SET stock=?, nombre=?, updated_at=? WHERE id=7
var_dump($p->isDirty()); // sincronizado con la base
var_dump($p->wasChanged('stock'));
var_dump($p->wasChanged('email'));isDirty(): «tengo modificaciones sin persistir» (true ANTES del save). wasChanged('col'): «el último save tocó esta columna». El UPDATE resultante toca exactamente stock + nombre + updated_at — el precio jamás viajó al servidor. Menos datos, menos riesgo de pisar cambios ajenos, SQL mínimo. En el manual MVC lográbamos algo parecido comparando arreglos a mano; aquí es nativo.
update() sobre la instancia
<?php
Producto::find(8)->update(['precio' => 25.50]);
// equivalente a: $p = find(8); $p->precio = ...; $p->save();
// Y para refrescar desde base si otro proceso pudo cambiarla:
$p = Producto::find(8)->refresh(); // re-hidrata desde MariaDBOjo: este update() de INSTANCIA respeta dirty tracking internamente. No confundirlo con el update() del BUILDER del capítulo 12, que dispara UPDATE directo masivo sin pasar por objetos.
Borrados: entidad, lote y cascada viva
<?php
// 1) Borrar UNA entidad concreta:
$cliente = Cliente::where('email', 'central@cafe.pe')->first();
$cliente->delete(); // DELETE WHERE id=32
// 2) Borrar por pk(s) directamente:
Producto::destroy([9, 10]); // retorna cuantas filas cayeron
// 3) La cascada de la FK (cap. 8) en accion:
$pedido = Pedido::find(50);
$pedido->delete(); // sus detalles caen solosNinguna línea de código borró los detalles: la FK ON DELETE CASCADE que
declaramos en el capítulo 8 los elimina en servidor. Y el caso contrario también protege:
intentar borrar un cliente CON pedidos revienta con error de FK (RESTRICT) —
exactamente el comportamiento que elegimos a propósito en el esquema.
La decisión final: modelo vs builder
| Situación | Herramienta | Pero ¿por qué? |
|---|---|---|
| Editar un registro concreto desde un formulario | Modelo + save() | dirty tracking + eventos |
| Alza de precios del 5% a 4 000 productos | Builder update() | 1 UPDATE, cero hidratación |
| Cargar una orden completa para mostrar | Modelo find() | objetos, relaciones (Parte IV) |
| Reporte agregado multi-tabla | Builder join/sum | el motor resume mejor |
| Dar de baja un cliente puntual | $entidad->delete() | intención explícita |
Regla de casa en una frase: el modelo para ENTIDADES vivas, el builder para VOLÚMENES y reportes. Ambos conviven sin fricción — comparten la misma conexión de Capsule y los mismos bindings seguros.
Puntos clave
- save() genera UPDATE mínimo gracias a isDirty/wasChanged.
- destroy() borra por pks; delete() por instancia; CASCADE hace familia.
- FKs RESTRICT/CASCADE son tu capa de integridad sin código extra.
- Entidad viva → modelo; volumen/reporte → builder. Sin culpa.
17 · Paginación simple
Básico ~13 minEl listado del CRUD del manual MVC crecía sin freno: a las cien filas el usuario dejaba de scrollear. La cura era siempre la misma — servir la información en porciones. Aquí empaquetamos esa cura en un helper reutilizable con el builder que ya dominamos, y cerramos la Parte III.
- Traducir «página N» a LIMIT/OFFSET con skip() y take().
- Calcular el total de páginas con count() + ceil().
- Poner defensas: página inválida, orden indeterminado y overflow.
De página a OFFSET
La aritmética cabe en una línea: para mostrar $por filas por página, la página
$pagina empieza en la fila ($pagina - 1) * $por. Eso es exactamente
lo que ya usamos como skip/take en el capítulo 11 — solo que ahora lo encapsulamos:
<?php
// helpers.php
function paginar(string $tabla, int $pagina = 1, int $por = 12): array
{
$pagina = max(1, $pagina); // defensa: nada de 0 ni negativos
$total = DB::table($tabla)->count();
$paginas = (int) ceil($total / $por); // ceil(): si sobran filas, pagina extra
$filas = DB::table($tabla)
->orderBy('id') // SIEMPRE ordenar antes de limitar
->skip(($pagina - 1) * $por)
->take($por)
->get();
return ['datos' => $filas, 'pagina' => $pagina,
'paginas' => $paginas, 'total' => $total];
}Ese orderBy() no es decoración: sin ORDER BY, MariaDB devuelve las filas en
el orden que le resulte conveniente — y la «página 2» podría repetir o saltar filas entre
clics. Orden fijo = páginas reproducibles. Es la misma regla del reporte CSV del MVC:
primero se ordena, después se acota.
El catálogo por porciones
<?php
require __DIR__ . '/boot.php';
require __DIR__ . '/helpers.php';
$r = paginar('productos', pagina: 3, por: 12);
echo "Pagina {$r['pagina']} de {$r['paginas']} ({$r['total']} productos)\n";
foreach ($r['datos'] as $fila) {
printf("%02d. %-14s S/ %s\n", $fila->id, $fila->nombre, $fila->precio);
}40 productos ÷ 12 por página = 4 páginas exactas gracias a ceil(); con 41
habría una quinta página con UNA sola fila — correcto aunque se vea solitario. Y fíjate en
la sintaxis pagina: 3: argumentos con nombre, PHP moderno al servicio de la
legibilidad.
Overflow: pedir más de lo que hay
<?php
$r9 = paginar('productos', pagina: 99, por: 12);
var_dump($r9['pagina']); // seguimos en 99...
var_dump($r9['datos']->isEmpty()); // ...pero la porcion viene vaciaEl helper no inventa magia: una página fuera de rango simplemente no tiene filas. En una app real decides tú: redirigir a la última página válida, mostrar «sin resultados» con su botón de vuelta, o clamp como hicimos con el mínimo. Lo importante es que la DECISIÓN sea explícita, no un bug descubierto por el usuario.
->paginate(12) de
Laravel vive en otro paquete (illuminate/pagination) que trae además los
links de navegación renderizados. En nuestro laboratorio la envolvimos a mano en 15
líneas — y cuando lleguemos al framework, la tendrás gratis con $productos->links().
El músculo es este mismo: count + ceil + skip/take.Puntos clave
- página → offset: ($pagina - 1) * $por, servido con skip()/take().
- ceil(total / por) da el número real de páginas, con resto incluido.
- orderBy SIEMPRE antes de limit: sin orden no hay páginas estables.
- paginate() de Laravel es azúcar del paquete pagination; el fondo ya lo sabes.
18 · belongsTo en acción
Intermedio ~14 minEmpieza la Parte IV: las RELACIONES, la razón de ser de todo ORM. En el manual MVC el método buscarDeCliente() del repositorio armaba el JOIN a mano y mapeaba columnas a dedo. Aquí declaramos la relación UNA vez y el objeto viaja solo.
- Declarar belongsTo() respetando la convención cliente_id ← cliente().
- Distinguir $pedido->cliente (ejecuta y cachea) de $pedido->cliente() (builder).
- Reconocer a simple vista el patrón que provoca el N+1 del capítulo 20.
Los modelos se conocen entre sí
<?php
// modelos.php
class Cliente extends Model
{
protected $fillable = ['nombre', 'email'];
}
class Pedido extends Model
{
protected $fillable = ['cliente_id', 'total', 'estado'];
public function cliente()
{
return $this->belongsTo(Cliente::class);
}
}Leído en cristiano: «cada pedido PERTENECE A un cliente». La convención hace el resto:
el método se llama cliente(), así que Eloquent busca la FK
cliente_id — exactamente la columna que definimos en el capítulo 8. Si tu
columna se llamara distinta, se pasa como segundo argumento:
belongsTo(Cliente::class, 'id_cliente'). Nuestro esquema no lo necesita:
nombramos bien desde el día uno.
El acceso perezoso (lazy)
<?php
require __DIR__ . '/boot.php';
require_once __DIR__ . '/modelos.php';
$pedido = Pedido::findOrFail(1);
echo $pedido->cliente->nombre, "\n"; // 1ra vez: dispara la consulta
echo $pedido->cliente->email, "\n"; // ya esta cacheada: cero SQL extraEl pedido #1 apunta al cliente 8 — coherente con la semilla. La primera vez que tocas
->cliente, Eloquent consulta clientes WHERE id = 8, hidrata un
modelo Cliente completo (con sus accessors, sus relaciones futuras, todo) y lo guarda en la
propiedad interna $relations. Las siguientes lecturas salen de esa caché: mismo
objeto, sin SQL.
Paréntesis o sin paréntesis: NO es lo mismo
var_dump(get_class($pedido->cliente()));
var_dump(get_class($pedido->cliente));
echo $pedido->cliente()->toSql(), "\n";Con paréntesis: obtienes el OBJETO RELACIÓN — un builder con
superpoderes, listo para encadenar where(), orderBy(),
toSql()... y tú decides cuándo ejecutarlo con first()/get()/count().
Sin paréntesis: Eloquent ejecuta por ti (un first() implícito) y cachea el
resultado. Regla práctica: propiedad para LEER el dato; método con paréntesis cuando quieras
FILTRAR o contar antes de traer.
foreach (Pedido::all() as $p) { echo $p->cliente->nombre; }. Cada vuelta
dispara SU propia consulta porque cada pedido es una instancia distinta sin caché compartida.
Con 50 pedidos son 51 consultas. No lo arreglamos aquí: lo MEDIMOS con cronómetro y query
log en el capítulo 20, y lo curamos en el 21.Puntos clave
- belongsTo() = «la FK vive en MI tabla» (pedidos.cliente_id).
- Convención: metodo cliente() ↔ columna cliente_id. Nombrar bien paga.
- ->cliente ejecuta+cachea; ->cliente() es builder filtrable.
- Acceso lazy dentro de un loop = N+1 latente. Ya lo verás medido.
19 · hasMany en ambas manos
Intermedio ~15 minSi belongsTo mira hacia arriba (el pedido señala a SU cliente), hasMany mira hacia abajo (el cliente tiene SUS pedidos). Son el mismo cable de la FK visto desde los dos extremos — y con ellos completamos la familia: Cliente → Pedido → Detalle.
- Declarar hasMany() como inverso exacto del belongsTo del capítulo anterior.
- Manejar una tabla irregular declarando $table a mano (lección de bases legadas).
- Contar sin traer filas y crear registros desde la relación.
El inverso exacto
<?php
class Cliente extends Model
{
protected $fillable = ['nombre', 'email'];
public function pedidos()
{
return $this->hasMany(Pedido::class);
}
}«Un cliente TIENE MUCHOS pedidos». La convención es simétrica: Eloquent deduce que la FK
vive en la tabla del OTRO modelo y se llama pedido_id — nombre del método en
singular + _id. Misma FK, dos perspectivas:
| Relación | Dónde vive la FK | Lectura |
|---|---|---|
| $pedido->cliente (belongsTo) | pedidos.cliente_id | muchos → uno |
| $cliente->pedidos (hasMany) | pedidos.cliente_id | uno → muchos |
Colección vs contador
<?php
$cliente = Cliente::where('email', 'cliente8@correo.pe')->first();
foreach ($cliente->pedidos as $p) { // propiedad: TRAJO todas las filas
printf("pedido #%d %-11s S/ %s\n", $p->id, $p->estado, $p->total);
}
echo 'total: ', $cliente->pedidos()->count(), "\n"; // metodo: COUNT(*) en baseLa propiedad devuelve una Colección ya hidratada (ideal para recorrerla
y mostrarla). El método con paréntesis sigue siendo un builder: ->count()
ejecuta un COUNT en MariaDB sin mover ni una fila de datos. ¿Necesitas solo el número?
No pagues el flete de traer modelos completos.
Detalle: cuando la tabla no sigue la regla
<?php
class Detalle extends Model
{
// Eloquent buscaria la tabla "detalles"... y no existe:
protected $table = 'pedido_detalles';
protected $fillable = ['pedido_id', 'producto_id', 'cantidad', 'precio_unitario'];
public function pedido()
{
return $this->belongsTo(Pedido::class);
}
}En el capítulo 14 vimos que la clase Venta acabaría en la tabla
ventas sin escribir nada. Pero las bases heredadas rara vez cooperan: tablas con
prefijos, nombres compuestos, plurales raros. $table es la válvula de escape —
una línea y el modelo apunta donde toca. Es la diferencia entre adaptar tu código al
diccionario o renombrar 40 tablas en producción.
Contar SIN hidratar: withCount
<?php
$top = Cliente::withCount('pedidos')
->orderByDesc('pedidos_count')
->orderBy('clientes.id')
->limit(3)
->get(['id', 'nombre']);
foreach ($top as $c) {
printf("%s -> %d pedido(s)\n", $c->nombre, $c->pedidos_count);
}withCount() inyecta una columna virtual pedidos_count calculada
con una subconsulta COUNT — sin cargar los pedidos. En nuestra semilla, los pedidos 31–50
repiten a los clientes de los primeros 30, así que hay EMPATE masivo en 2 pedidos; el
segundo orderBy desempata por id: determinismo otra vez.
Crear desde la relación
<?php
$nuevo = $cliente->pedidos()->create([
'total' => 0,
'estado' => 'REGISTRADO',
]);
echo "pedido #{$nuevo->id} para el cliente {$nuevo->cliente_id}\n";Nadie escribió 'cliente_id' => 8: la relación lo setea sola. Es el eco
directo del insertGetId del capítulo 12, pero con intención declarada — el
código dice «un pedido DE ESTE cliente» y la integridad queda garantizada. El total en 0
respeta la regla del MVC: el total se CALCULA desde los detalles, jamás se inventa.
Puntos clave
- hasMany() = «la FK vive en LA OTRA tabla» (pedidos.cliente_id).
- $table declara tablas irregulares: bases legadas domesticadas.
- Propiedad trae Colección; ->relacion()->count() cuenta sin filas.
- ->relacion()->create() setea la FK automáticamente. Sin insertGetId manual.
20 · El problema N+1
Avanzado ~15 minEl bug favorito de los ORM mal usados tiene nombre propio: N+1. Una consulta para traer la lista... y una más POR CADA fila. El código se ve inocente, los tests en local pasan — y en producción la página tarda segundos. Hoy lo provocamos, lo medimos con cronómetro incluido y dejamos la regla que lo evita para siempre.
- Provocar el N+1 clásico: relación lazy dentro de un foreach.
- Medirlo con enableQueryLog() / getQueryLog(): 50 filas = 51 consultas.
- Calcular su costo real multiplicando por la latencia del servidor.
El loop inocente
<?php
require __DIR__ . '/boot.php';
require_once __DIR__ . '/modelos.php';
DB::connection()->enableQueryLog(); // empezamos a grabar
$pedidos = Pedido::all(); // consulta #1
foreach ($pedidos as $pedido) {
echo $pedido->cliente->nombre, "\n"; // ¡una consulta POR VUELTA!
}
$log = DB::connection()->getQueryLog();
echo 'consultas totales: ', count($log), "\n";51 = 1 (la lista de pedidos) + 50 (un SELECT de cliente por pedido). Cada vuelta pide SU cliente porque cada modelo es una instancia independiente sin caché compartida. Y lo peor: mirando el código nadie lo nota — son líneas perfectamente razonables.
La anatomía del desastre
select * from `pedidos`
select * from `clientes` where `clientes`.`id` = ? limit 1 -- pedido 1
select * from `clientes` where `clientes`.`id` = ? limit 1 -- pedido 2
-- ... exactamente la misma sentencia, 48 veces mas ...La MISMA sentencia repetida cambiando un binding. MariaDB las ejecuta rápido, pero cada ida y vuelta paga peaje. Multiplica por tu latencia real:
| Latencia al server | 51 viajes | Sensación del usuario |
|---|---|---|
| 0.5 ms (localhost) | ~26 ms | ni se entera |
| 5 ms (misma región cloud) | ~255 ms | aceptable, pero innecesario |
| 50 ms (cross-region / wifi malo) | ~2.6 s | «¿se colgó?» |
| 200 ms (3G marginal) | ~10 s | usuario perdido |
Ahí está la trampa completa: en TU máquina vuela (0.5 ms), en producción agoniza. Es el mismo fenómeno del capítulo 11 con OFFSET profundo — problemas invisibles hasta que los datos crecen o la red se estira.
Cronómetro honesto
<?php
$t0 = hrtime(true);
foreach (Pedido::all() as $p) {
$x = $p->cliente->nombre;
}
printf("lazy: %.1f ms\n", (hrtime(true) - $t0) / 1e6);DB::whenQueryingForLongerThan()
para vigilar esto por request. Regla de laboratorio: activar el log, medir, DESACTIVAR.with() — y llega en el siguiente
capítulo: las mismas 51 consultas convertidas en exactamente 2.Puntos clave
- N+1 = 1 consulta por la lista + N por cada fila procesada.
- enableQueryLog() + count() lo convierte en número, no en corazonada.
- En localhost no duele; la latencia de producción lo multiplica.
- Regla inamovible: ninguna consulta dentro de un loop.
21 · Eager loading con with()
Intermedio ~14 minUna sola palabra cura el N+1 del capítulo anterior: with(). Le pides
a Eloquent las relaciones POR ANTICIPADO y él agrupa todo en el mínimo de consultas
posible. Mismo resultado en pantalla, 51 consultas convertidas en 2.
- Cargar relaciones por adelantado con with() y entender sus 2 consultas.
- Combinar varias relaciones y anidarlas con la sintaxis de punto.
- Filtrar dentro del eager loading y cargar tarde con load().
Re-medir el loop culpable
<?php
require __DIR__ . '/boot.php';
require_once __DIR__ . '/modelos.php';
DB::connection()->enableQueryLog();
$pedidos = Pedido::with('cliente')->get(); // TODO se resuelve aqui arriba
foreach ($pedidos as $pedido) {
echo $pedido->cliente->nombre, "\n"; // cero SQL extra: ya viene cargado
}
echo 'consultas totales: ', count(DB::connection()->getQueryLog()), "\n";Las DOS consultas son estas:
select * from `pedidos`
select * from `clientes` where `clientes`.`id` in (?, ?, ?, ?, ?, ...)Eloquent recolectó los 50 cliente_id distintos y los pidió TODOS en un solo
WHERE id IN (...). El bucle de abajo no cambia ni una línea: sigue leyendo
->cliente->nombre — pero ahora encuentra al cliente YA cacheado en cada
instancia. Lazy vs eager es la misma API, distinto momento de hidratación.
Varias relaciones y relaciones anidadas
// Varias de golpe (array):
$pedidos = Pedido::with(['cliente', 'detalles'])->limit(10)->get();
// Anidadas con punto: detalles Y, de cada detalle, su producto:
$pedidos = Pedido::with('detalles.producto')->limit(10)->get();
foreach ($pedidos as $pedido) {
foreach ($pedido->detalles as $d) {
printf(" %dx %s\n", $d->cantidad, $d->producto->nombre);
}
}'detalles.producto' carga dos niveles sin N+1 intermedio: pedidos + detalles
+ productos en 3 consultas totales, sin importar cuántos haya. Sin eager loading, ese
segundo foreach habría disparado una consulta de producto POR DETALLE — el problema del
capítulo 20 multiplicándose en silencio.
load(): cuando te enteras tarde
$clientes = Cliente::orderBy('id')->limit(5)->get();
// ...20 lineas despues descubres que SI necesitas los pedidos...
$clientes->load('pedidos'); // 1 consulta extra, no 5load() es eager loading sobre una Colección QUE YA EXISTE: útil cuando la
decisión de qué cargar no dependía de ti (un método recibió modelos hechos por otro).
Misma mecánica IN (...), mismo ahorro.
Filtrar DENTRO del eager loading
<?php
$pedidos = Pedido::with(['detalles' => function ($q) {
$q->where('cantidad', '>=', 4); // solo lineas grandes
}])
->where('estado', 'PAGADO')
->get();
echo $pedidos->count(), " pedidos pagados, con sus detalles grandes\n";Ojo con la distinción fina: el closure filtra QUÉ detalles se cargan, no QUÉ pedidos
llegan — los 20 PAGADO están todos, algunos con su Colección de detalles vacía. Si lo que
quieres es filtrar PEDIDOS según SUS hijos («clientes con pedidos pagados»), esa es otra
herramienta: whereHas(), protagonista del siguiente capítulo.
Puntos clave
- with() agrupa las FK en WHERE IN: N+1 consultas → 2.
- Punto para anidar ('detalles.producto'), array para varias.
- load() = with() tardío sobre Colecciones existentes.
- El closure de with() filtra los HIJOS cargados; whereHas filtra los padres.
22 · Filtrar por hijos: whereHas
Avanzado ~15 minwith() carga hijos; whereHas FILTRA PADRES según sus hijos. «Clientes que tienen al menos un pedido pagado», «productos sin ninguna venta»: preguntas de negocio que en el mundo JOIN a mano terminaban en gimnasia de LEFT JOIN y aggregates. Aquí se dicen en cristiano.
- Filtrar padres por existencia (y condición) de hijos con whereHas().
- Usar los atajos has(), doesntHave() y whereDoesntHave().
- Ver el EXISTS generado y contrastar con la vía LEFT JOIN del capítulo 13.
La pregunta y su SQL
<?php
require __DIR__ . '/boot.php';
require_once __DIR__ . '/modelos.php';
$sql = Cliente::whereHas('pedidos', fn ($q) => $q->where('estado', 'PAGADO'))
->toSql();
echo $sql, "\n";
$conPago = Cliente::whereHas('pedidos', fn ($q) => $q->where('estado', 'PAGADO'))
->get();
echo $conPago->count(), " clientes tienen al menos un pago\n";Ahí está la traducción completa: whereHas genera un EXISTS correlacionado — «dame clientes PARA LOS QUE EXISTA un pedido suyo con estado PAGADO». El closure recibe el builder de DENTRO (ya filtrado por la correlación automática) y tú le encadenas las condiciones extra. Doce clientes de treinta: la semilla queda retratada.
La familia de atajos
<?php
// Tiene AL MENOS UN pedido (sin importar estado):
echo 'con pedidos: ', Cliente::has('pedidos')->count(), "\n";
// No tiene NINGUNO:
echo 'sin pedidos: ', Cliente::doesntHave('pedidos')->count(), "\n";Cero clientes huérfanos — hallazgo legítimo, no bug: la fórmula del seed asigna pedidos barriendo los 30 clientes completos. La lección importa igual: una respuesta vacía también es información, y mejor descubrirla con una consulta que con un reporte mal nacido. Ahora bien, «sin pedidos» era la pregunta fácil. La interesante lleva filtro:
<?php
// Frios: clientes que NO TIENEN ni un solo pedido PAGADO:
$frios = Cliente::whereDoesntHave('pedidos', fn ($q) => $q->where('estado', 'PAGADO'))
->orderBy('id')
->get(['id', 'nombre']);
echo $frios->count(), " clientes sin ningun cobro:\n";
foreach ($frios->take(5) as $c) {
echo ' ', $c->nombre, "\n";
}Dieciocho de treinta: más de la mitad de la cartera no ha pagado nada todavía. El mismo whereDoesntHave acepta closure — la negación EXACTA del whereHas del inicio. Nota el detalle de oro: whereHas/whereDoesntHave NO traen los pedidos, solo deciden qué clientes pasan el filtro. Para MOSTRAR los pedidos sigue siendo trabajo de with().
Contrastes: la misma pregunta sin ORM
<?php
// Vía cap. 13 (joins a mano): agrupar y preguntar por el agregado
$vieja = Cliente::query()
->leftJoin('pedidos', 'pedidos.cliente_id', '=', 'clientes.id')
->groupBy('clientes.id')
->havingRaw('COALESCE(SUM(pedidos.estado = ?), 0) = 0', ['PAGADO'])
->get(['clientes.id', 'clientes.nombre']);
echo 'via joins: ', $vieja->count(), "\n";
// Alternativa intermedia con withCount filtrado:
$nueva = Cliente::withCount(['pedidos' => fn ($q) => $q->where('estado', 'PAGADO')])
->having('pedidos_count', '=', 0)
->count();
echo 'via withCount: ', $nueva, "\n";Tres caminos, dieciocho clientes, tres niveles de lectura. El LEFT JOIN + HAVING funciona, pero obliga a pensar en agregados y COALESCE antes que en el negocio. withCount es razonable cuando ADEMÁS quieres mostrar el número. Para «¿existe o no existe?», whereHas gana por KO: declara intención y delega el SQL.
Puntos clave
- whereHas filtra PADRES por sus hijos y compila a EXISTS correlacionado.
- has / doesntHave son el caso simple; los closures añaden condiciones.
- Colección vacía = hallazgo. Audita antes de asumir error.
- whereHas decide QUIÉNES; with() trae EL CONTENIDO. Compañeros, no rivales.
23 · Dashboard sin N+1
Intermedio ~16 minEjercicio integrador de la Parte IV: un dashboard gerencial completo — cifras, ranking, actividad reciente y alerta de catálogo — con UN presupuesto declarado de consultas que se audita al final. Todo lo aprendido desde el capítulo 18, en una sola pieza.
- Declarar un presupuesto de consultas ANTES de codificar y respetarlo.
- Combinar builder agregado, withCount, with() y whereDoesntHave en un script.
- Auditar con getQueryLog() y hacer checklist de la Parte.
KPI 1 · Facturado por estado (builder agregado)
<?php
require __DIR__ . '/boot.php';
require_once __DIR__ . '/modelos.php';
DB::connection()->enableQueryLog();
$kpi = DB::table('pedidos')
->selectRaw('estado, COUNT(*) AS cantidad, SUM(total) AS monto')
->groupBy('estado')
->orderByDesc('monto')
->get();
foreach ($kpi as $f) {
printf("%-11s %2d pedidos S/ %s\n",
$f->estado, $f->cantidad, number_format($f->monto, 2));
}Consulta 1. El motor resume 50 filas en 3 líneas — trabajo de SUM/GROUP BY, no de PHP. Volumen agregado = builder, como dicta la regla del capítulo 16.
KPI 2 · Top clientes (withCount)
$top = Cliente::withCount('pedidos')
->orderByDesc('pedidos_count')
->orderBy('id')
->limit(5)
->get(['nombre', 'pedidos_count']);
foreach ($top as $c) {
printf("%s (%d pedidos)\n", $c->nombre, $c->pedidos_count);
}Consulta 2. Empate técnico en 2 pedidos para media semilla — el orderBy por id garantiza el mismo podio siempre. Ni un pedido fue cargado para contarlos.
KPI 3 · Actividad reciente (eager loading)
$recientes = Pedido::with('cliente:id,nombre')
->orderByDesc('created_at')
->limit(10)
->get(['id', 'cliente_id', 'total', 'estado']);
foreach ($recientes as $p) {
printf("#%d %-12s %-11s S/ %s\n",
$p->id, $p->cliente->nombre, $p->estado, $p->total);
}Consultas 3 y 4. La lista + el IN (...) de clientes: el patrón del
capítulo 21 en acción. Detalle fino: with('cliente:id,nombre') pide solo dos
columnas del hijo, pero la PK id va OBLIGATORIA en la lista — sin ella el
JOIN interno del eager matching no puede emparejar y todos los clientes llegarían null.
Error clásico de principiante, ahora vacunado.
KPI 4 · Catálogo dormido (whereDoesntHave anidado)
$dormidos = Producto::whereDoesntHave('detalles', fn ($d) =>
$d->whereHas('pedido', fn ($p) => $p->where('estado', 'PAGADO'))
)
->orderBy('id')
->get(['id', 'nombre']);
echo $dormidos->count(), " productos sin venderse EN LO COBRADO:\n";
foreach ($dormidos as $p) {
echo ' ', $p->nombre, "\n";
}Consulta 5. whereDoesntHave anidado: producto cuyos detalles NO pertenezcan a ningún pedido pagado. Nótese la pregunta afinada: «nunca vendidos» a secas da CERO en esta semilla (todo el catálogo rotó); «fuera de lo cobrado» sí destapa 8 productos cuyo ingreso sigue en el aire. La precisión del enunciado ES parte del análisis.
Auditoría y checklist de la Parte
$total = count(DB::connection()->getQueryLog());
printf("presupuesto: 6 | usado: %d | %s\n",
$total,
$total <= 6 ? 'DENTRO DE PRESUPUESTO' : 'PRESUPUESTO REVENTADO');Cuatro KPIs, cinco consultas, cero loops sospechosos. Checklist de lo aprendido en la Parte IV: relaciones declaradas una vez (18–19), N+1 reconocido y medido (20), curado con with() (21), filtrado por hijos con whereHas (22). Con este vocabulario ya piensas en consultas COMO el dashboard: piezas declarativas, cada una con su intención.
Puntos clave
- Presupuesto de consultas primero, código después, auditoría siempre.
- Agregados → builder; rankings → withCount; listados hijos → with().
- En eager con columnas seleccionadas, la PK del hijo es obligatoria.
- Afina el enunciado: «sin venta» y «sin venta cobrada» son mundos distintos.
24 · Scopes locales
Intermedio ~13 minCuenta cuántas veces escribiste where('estado', 'PAGADO') en lo que va
de manual. Cada repetición es una regla de negocio viviendo suelta por el código,
esperando a divergirse. Los scopes la encierran en el modelo con nombre de dominio.
- Definir scopes con scopeXxx() y llamarlos como métodos normales.
- Encadenar scopes y pasarles parámetros.
- Refactorizar condiciones repetidas del capítulo 11 en vocabulario de dominio.
De condición suelta a palabra del negocio
<?php
// modelos.php
class Pedido extends Model
{
protected $fillable = ['cliente_id', 'total', 'estado'];
public function cliente()
{
return $this->belongsTo(Cliente::class);
}
public function scopePagados($query)
{
return $query->where('estado', 'PAGADO');
}
public function scopeRangoTotal($query, float $min, float $max)
{
return $query->whereBetween('total', [$min, $max]);
}
public function scopeDeCliente($query, int $clienteId)
{
return $query->where('cliente_id', $clienteId);
}
}La convención hace su magia otra vez: scopePagados() se invoca como
->pagados(). Eloquent quita el prefijo scope, pasa minúscula inicial y le
inyecta el builder en el primer parámetro. El método DEBE devolver el query — así sigue la
cadena viva.
Composición encadenada
<?php
require __DIR__ . '/boot.php';
require_once __DIR__ . '/modelos.php';
echo 'pagados: ', Pedido::pagados()->count(), "\n";
$medios = Pedido::pagados()
->rangoTotal(100, 300)
->orderBy('id')
->get(['id', 'total']);
echo 'pagados entre S/ 100 y S/ 300: ', $medios->count(), "\n";
echo $medios->pluck('id')->implode(', '), "\n";
echo 'del cliente 8: ', Pedido::deCliente(8)->count(), "\n";Léelo en voz alta: «pedidos pagados, en rango de total 100 a 300». El código dice QUÉ quieres; los WHERE quedaron dentro del modelo. Y como cada scope devuelve un builder común, la cadena es libre: pagados + rango, deCliente + pagados, tres scopes juntos — combinaciones, no copias.
El refactor honesto
Vuelve al capítulo 11 mentalmente: contamos where('estado', ...) en al menos
cuatro scripts distintos (listados, agregados, dashboard). Si mañana el negocio decide que
existirá también el estado ENVIADO, tocarías todos esos sitios rezando por no olvidar uno.
Con scope, la definición vive en UNA línea de UN archivo:
| Antes (cap. 11) | Después (scope) | Quién manda |
|---|---|---|
| where('estado', 'PAGADO') x N sitios | Pedido::pagados() | el modelo |
| whereBetween('total', [...]) repetido | rangoTotal(100, 300) | el modelo |
| where('cliente_id', $id) disperso | deCliente($id) | el modelo |
| criterio nuevo = buscar-y-reemplazar | regla nueva = 1 método | nadie sufre |
Es exactamente el espíritu del repositorio del manual MVC — métodos con intención
(buscarPagados(), porRango())— pero sin clase extra: la lógica de
consulta VIVE en el modelo que representa la tabla. Active Record cobrando la promesa.
Puntos clave
- scopeXxx() → llamada xxx(); primer argumento = builder a devolver.
- Los scopes encadenan entre sí y aceptan parámetros tipados.
- Regla de casa: una condición de negocio repetida dos veces ya merece scope.
- Globales existen pero ocultan comportamiento; locales ganan por explícitos.
25 · Accessors, mutators y casts
Avanzado ~15 minLo que MariaDB guarda y lo que tu app necesita RARA VEZ coinciden: TINYINT que debería ser bool, TIMESTAMP que quieres como fecha, emails con mayúsculas accidentales. Eloquent pone la frontera en el modelo: casts para TIPOS, accessors/mutators para TRANSFORMACIONES.
- Tipar columnas con $casts moderno: boolean, datetime, decimal.
- Normalizar al escribir (set) y presentar al leer (get) con Attribute::make().
- Blindar el dinero: DECIMAL jamás convertido a float.
Casts: el tipo correcto sin ceremonia
<?php
// modelos.php
class Producto extends Model
{
protected $fillable = ['nombre', 'precio', 'stock', 'activo'];
protected $casts = [
'activo' => 'boolean',
'created_at' => 'datetime',
'precio' => 'decimal:2',
];
}Tres líneas y la tabla se comporta como debe. El cast boolean convierte el TINYINT(1) del esquema (capítulo 8) en true/false real; datetime entrega objetos Carbon ya conocidos del capítulo 14; decimal:2 formatea a dos decimales SIN degradar a float. Comprobación:
$p = Producto::find(9); // Te 1: activo = 0 en el seed
var_dump($p->activo);
var_dump(get_class($p->created_at));
echo gettype($p->precio), ' -> ', $p->precio, "\n";El precio llega como string — y así seguirá. ¿Por qué celebrarlo? Porque el float binary no sabe representar 0.10 exacto:
var_dump(0.1 + 0.2 === 0.3); // el clasico
var_dump('0.10' + '0.20' === 0.3); // los strings NO salvanAmbos fallan por la misma razón: cualquier aritmética numérica — incluso entre strings numéricos— pasa por float binario, y 0.10 no existe exactamente ahí. La regla ya estaba clavada en el esquema — DINERO NUNCA FLOAT — y los casts la respetan: DECIMAL entra como string, sale como string, y PHP solo hace aritmética cuando TÚ lo decides con funciones bc* o redondeos explícitos.
Attribute::make: transformar al entrar y al salir
<?php
use Illuminate\Database\Eloquent\Casts\Attribute;
class Cliente extends Model
{
protected $fillable = ['nombre', 'email'];
protected function email(): Attribute
{
return Attribute::make(
set: fn ($value) => strtolower(trim($value)),
);
}
protected function nombre(): Attribute
{
return Attribute::make(
get: fn ($value) => ucwords(strtolower($value)),
);
}
}Sintaxis moderna (argumentos con nombre, eco del capítulo 17): set corre ANTES de guardar, get DESPUÉS de leer. Un solo método por columna, sin nombres mágicos tipo setEmailAttribute heredados de versiones viejas. La prueba de fuego:
$c = new Cliente([
'nombre' => 'cafe central sac',
'email' => ' Contacto@CafeCentral.PE ',
]);
$c->save();
echo $c->refresh()->email, "\n"; // mutado ANTES de llegar a base
echo $c->nombre, "\n"; // presentado bonito AL LEER
echo $c->getRawOriginal('nombre'), "\n"; // lo que REALMENTE esta guardadoTres lecturas, tres verdades: el email entró normalizado (trim + minúsculas) y a base
llegó limpio; el nombre SE GUARDÓ tal cual pero SE PRESENTA capitalizado; y
getRawOriginal() te deja ver el dato crudo cuando necesitas auditar. Presentación
y almacenamiento dejaron de pelearse.
Puntos clave
- $casts tipa columnas: boolean/datetime/decimal cubren el 90 %.
- Attribute::make(get:, set:) concentra transformación de una columna.
- Dinero: DECIMAL → string SIEMPRE; floats solo con decisión explícita.
- Normaliza al escribir (set), presenta al leer (get), audita con rawOriginal.
26 · Colecciones
Intermedio ~14 minCada get() del manual devolvió una y ya la venías usando sin ceremonia. Hora de abrirle el capó: la Collection es un arreglo con superpoderes — el mismo pipeline map/filter/reduce que dominaste con array_* en el capítulo 26 del MVC, pero fluido, tipado por objetos y con cien métodos listos.
- Encadenar map/filter/reduce/pluck/groupBy sobre resultados de Eloquent.
- Traducir tu vocabulario array_* al pipeline fluido de Collection.
- Saber cuándo NO usarla: volumen agregado se queda en base.
El pipeline sobre los detalles
<?php
require __DIR__ . '/boot.php';
require_once __DIR__ . '/modelos.php';
$pedido = Pedido::with('detalles')->find(1);
$d = $pedido->detalles; // Collection de Detalle
echo $d->pluck('producto_id')->implode(', '), "\n";
echo 'unidades: ', $d->sum('cantidad'), "\n";
$subtotales = $d->map(fn ($x) => $x->cantidad * $x->precio_unitario);
printf("%s = %.2f\n",
$subtotales->map(fn ($v) => number_format($v, 2))->implode(' + '),
$d->sum(fn ($x) => $x->cantidad * $x->precio_unitario));El total coincide hasta el centavo con lo guardado en pedidos.total — el seed recalculó con esta MISMA fórmula (capítulo 9). Y mira el diccionario traducido:
| Mundo array_* (MVC) | Colección Eloquent | Matiz |
|---|---|---|
| array_column($filas, 'id') | $d->pluck('id') | también acepta ->metodo() |
| array_filter($a, fn) | $d->filter(fn) | conserva claves → values() |
| array_map(fn, $a) | $d->map(fn) | fn recibe PRIMERO el valor |
| array_reduce($a, fn, 0) | $d->reduce(fn, 0) | y sum()/avg() ya vienen listos |
| foreach + if + acumulador | $d->groupBy('estado') | una línea, sin estado manual |
filter, sortByDesc y groupBy
$grandes = $d->filter(fn ($x) => $x->cantidad >= 4)->values();
echo $grandes->count(), " linea(s) grandes\n";
$campeon = Pedido::all()->sortByDesc('total')->first();
printf("el pedido #%d es el mas caro: S/ %s\n", $campeon->id, $campeon->total);
$porEstado = Pedido::all()->groupBy('estado');
echo $porEstado->map->count()->toJson(), "\n";Tres joyas en seis líneas. ->values() re-indexa tras filter (array_filter nunca lo hacía y generaba agujeros). sortByDesc devuelve UNA colección nueva — inmutabilidad de fábrica, nada de sort in-place sorpresa. Y ->map->count() es el higher-order message: «llama count() en cada colección interna» — groupBy dentro de groupBy sin closure.
Cuándo la colección NO es la respuesta
Todo este capítulo operó sobre datos QUE YA ESTABAN EN MEMORIA. Si el reporte pide
sum('total') sobre MEDIO MILLÓN de pedidos, traerlos para sumarlos en PHP es
contratar una flota para pesar una ballena... en la balanza equivocada:
| Pregunta | Lugar correcto | Razón |
|---|---|---|
| Total de 500 000 pedidos | builder: sum('total') | el motor suma sin traer filas |
| Subtotal de LOS detalles ya cargados | colección: sum(fn) | cero consultas extra |
| Ranking completo ordenado | builder: orderBy + limit | ordenar 500 k en PHP = RAM llorando |
| Reordenar los 20 ya en pantalla | colección: sortByDesc | ya están aquí; no re-consultar |
Eco exacto de la regla del capítulo 16 — entidad vs volumen— ahora en su eje memoria: agregación grande = SQL; transformación de lo ya cargado = Collection.
Puntos clave
- pluck/filter/map/reduce/groupBy/sum: el arsenal array_*, versión fluida.
- ->values() tras filter; sortBy* no muta; map->metodo() para colecciones anidadas.
- La colección opera en MEMORIA: ideal para lo cargado, letal para volúmenes.
- Dudoso entre builder y colección? Pregunta: ¿los datos ya están aquí?
27 · Transacciones
Avanzado ~15 minUn pedido sin sus detalles es una venta fantasma; unos detalles sin pedido, basura huérfana. Cuando una operación toca VARIAS tablas, o se completa entera o no ocurre — eso es una transacción. La misma ley que el MVC promulgó en su capítulo 20, ahora con azúcar Eloquent.
- Ejecutar escrituras atómicas con DB::transaction(closure).
- Verificar el rollback: la excepción deshace TODO lo escrito.
- Conocer la variante manual beginTransaction/commit/rollBack.
La tercera pata de la familia
with('detalles') ya lo usábamos desde el capítulo 21; aquí está por fin declarado en Pedido. Con esto la familia queda cerrada en ambas direcciones:
<?php
// modelos.php (fragmento nuevo)
class Pedido extends Model
{
// ... scopePagados y cliente() ya vistos ...
public function detalles()
{
return $this->hasMany(Detalle::class);
}
}El closure transaccional
<?php
require __DIR__ . '/boot.php';
require_once __DIR__ . '/modelos.php';
$pedido = null;
DB::transaction(function () use (&$pedido) {
$pedido = Pedido::create([
'cliente_id' => 8,
'total' => 0,
'estado' => 'REGISTRADO',
]);
$pedido->detalles()->create([
'producto_id' => 5, 'cantidad' => 2, 'precio_unitario' => '10.65',
]);
$pedido->detalles()->create([
'producto_id' => 18, 'cantidad' => 1, 'precio_unitario' => '41.34',
]);
$pedido->update(['total' => '62.64']); // calculado, jamas recibido
});
printf("pedido #%d con %d lineas (total %s)\n",
$pedido->id, $pedido->detalles()->count(), $pedido->total);(El id exacto depende del historial de tu laboratorio; si vienes ejecutando los capítulos en orden, tocó el 52.) Si CUALQUIERA de las cuatro escrituras fallara — FK inválida, caída de conexión, tu propia excepción— MariaDB desharía TODAS las anteriores. Ni pedidos fantasma ni detalles huérfanos. Es el crearConDetalles() del repositorio MVC, pero expresado como intención directa.
Rollback a prueba de balas
<?php
$antes = Pedido::count();
try {
DB::transaction(function () {
Pedido::create(['cliente_id' => 8, 'total' => 999, 'estado' => 'PAGADO']);
throw new RuntimeException('pago sin detalles: operacion invalida');
});
} catch (RuntimeException $e) {
echo 'capturada: ', $e->getMessage(), "\n";
}
printf('pedidos antes: %d | despues: %d | %s\n',
$antes,
Pedido::count(),
$antes === Pedido::count() ? 'ROLLBACK VERIFICADO' : '?!');
El pedido «999» llegó a INGRESAR al buffer de escritura... y desapareció con la excepción. Eso es exactamente lo que quieres en un cobro real: si el paso 3 de 4 revienta, los pasos 1–2 no quedan colgados mintiendo sobre un pago que nunca existió.
La variante manual (y cuándo usarla)
DB::beginTransaction();
try {
// ... varias escrituras ...
DB::commit(); // todo bien: sellar
} catch (Throwable $e) {
DB::rollBack(); // cualquier mal: deshacer
throw $e; // ...y avisar hacia arriba
}Necesaria cuando el control escapa del closure: confirmar algo DESPUÉS de un webhook, esperar respuesta externa dentro de la misma unidad atómica. Para todo lo demás, el closure gana: imposible olvidar el rollBack porque la excepción lo dispara sola.
Puntos clave
- DB::transaction(fn): éxito=commit, excepción=rollback automático.
- Atomicidad = todas las escrituras o ninguna. Sin estados zombi.
- Variante manual para flujos donde el commit depende de terceros.
- Escritura multi-tabla → transacción. Sin excepciones (nunca mejor dicho).
28 · Inserciones masivas
Intermedio ~14 minCargar 500 filas con create() en un foreach son 500 viajes al servidor: funciona, pero paga taxi fila por fila. El insert() masivo mete el lote entero en UNA sentencia multi-valores — la misma técnica de tu CTE del capítulo 9, ejecutada desde PHP.
- Insertar lotes grandes con Model::insert([]) en una sola ida.
- Entender QUÉ se bypassa: eventos, casts y timestamps automáticos.
- Generar datos deterministas con módulos (eco CTE del capítulo 9).
El lote determinista
<?php
require __DIR__ . '/boot.php';
require_once __DIR__ . '/modelos.php';
$precios = Producto::pluck('precio', 'id'); // catalogo en memoria
$marca = '2020-01-01 00:00:00'; // sello del laboratorio
$lote = [];
for ($i = 1; $i <= 500; $i++) {
$prod = ($i * 7) % 40 + 1; // pseudoaleatorio DETERMINISTA
$lote[] = [
'pedido_id' => $i % 50 + 1,
'producto_id' => $prod,
'cantidad' => 1 + $i % 4,
'precio_unitario' => $precios[$prod],
'created_at' => $marca, // insert() NO estampa timestamps:
'updated_at' => $marca, // los pones TU
];
}Los módulos fabrican variación reproducible sin random: mismo lote en cualquier máquina, cualquier día — la filosofía exacta del seed SQL. Y el detalle crucial está comentado: insert() es SQL casi crudo, así que created_at/updated_at son responsabilidad TUYA.
Una ida, quinientas filas
$antes = Detalle::count();
Detalle::insert($lote); // UNA sentencia multi-valores
printf("antes: %d | ahora: %d\n", $antes, Detalle::count());Quinientas filas en un solo viaje. La tabla comparativa que cierra toda discusión:
| Vía | Viajes para 500 | Pasa por | Ideal para |
|---|---|---|---|
| create() x 500 | 500 | eventos, casts, dirty tracking | pocas entidades vivas |
| insert($lote) | 1 | nada: SQL directo | volumen bruto |
| insert por chunks de 100 | 5 | nada, paquetes seguros | miles+ de filas |
Ese «nada» es poder Y responsabilidad. En insert(): no corren eventos (creating/saving observados desaparecen), no aplican casts ni accessors del capítulo anterior, y $fillable no filtra — el arreglo va tal cual a SQL. Si necesitas esos superpoderes fila a fila, usa create(); si necesitas VELOCIDAD, insert() y tú te haces cargo de los timestamps (como hicimos).
Limpieza con la marca
$borrados = Detalle::where('created_at', '<', '2021-01-01')->delete();
echo "limpieza: {$borrados} filas del lote eliminadas\n";
echo 'detalles finales: ', Detalle::count(), "\n";El sello 2020-01-01 no fue capricho: identifica el lote SIN depender de ids autoincrementales — borrar por marca es idempotente aunque el script se ejecute dos veces. Patrón profesional para fixtures y pruebas de carga: entra masivo, sale limpio, base como si nada.
Puntos clave
- insert([lote]) = una sentencia multi-valores, N filas, un viaje.
- Bypasea eventos/casts/fillable/timestamps: poder con responsabilidades.
- Módulos para datos deterministas; marcas temporales para limpieza idempotente.
- Lotes gigantes van por chunks: respeta max_allowed_packet.
29 · chunk, lazy y cursor
Avanzado ~15 minget() sobre un millón de filas es suicidio de RAM: PHP hidrata un objeto por fila ANTES de que tu foreach empiece. Para procesar volúmenes existen tres herramientas de streaming — chunkById(), lazy() y cursor() — y elegir mal cuesta horas de proceso.
- Recorrer tablas enormes en tandas con chunkById() sin OFFSET profundo.
- Usar lazy() y cursor() para lectura streaming con memoria plana.
- Elegir herramienta con criterio: memoria vs velocidad vs escritura.
chunkById: el paginador industrial
<?php
require __DIR__ . '/boot.php';
require_once __DIR__ . '/modelos.php';
$tandas = 0;
$stock = 0;
Producto::chunkById(10, function ($chunk) use (&$tandas, &$stock) {
$tandas++;
$stock += $chunk->sum('stock');
});
printf("%d tandas | stock total %d unidades\n", $tandas, $stock);Cada tanda es una consulta independiente que trae SOLO 10 filas (WHERE id > último + LIMIT 10). Por eso cierra la advertencia del capítulo 11: skip/take con OFFSET profundo obliga al motor a recorrer y DESCARTAR todo lo anterior; chunkById pagina por llave — la posición exacta siempre, costo constante siempre.
lazy() y cursor(): el foreach que no explota
// lazy(): generador por tandas internas (1000 default)
$correos = 0;
foreach (Cliente::lazy() as $c) {
if (str_contains($c->email, '@correo.pe')) {
$correos++;
}
}
echo "via lazy: {$correos}\n";
// cursor(): UNA consulta, filas goteando desde PDO
$grandes = 0;
foreach (Pedido::where('total', '>', 300)->cursor() as $p) {
$grandes++;
}
echo "pedidos sobre S/ 300: {$grandes}\n";Ambos te dan el MISMO foreach cómodo; difieren adentro. lazy() sigue consultando por tandas (memoria acotada, N viajes). cursor() abre UNA consulta y las filas van goteando del buffer de MariaDB a tu loop (un solo viaje, memoria mínima, conexión ocupada hasta terminar). Con 40 productos no se nota; con 5 millones, es la diferencia entre terminar y ser OOM-killed.
| Herramienta | Memoria | Viajes | Territorio ideal |
|---|---|---|---|
| get() | todas las filas | 1 | listas pequeñas/medianas |
| chunkById(N, fn) | N filas | N por página | ESCRIBIR mientras lees |
| lazy() | tanda actual | varios | leer-comfortable por lotes |
| cursor() | ~1 fila (PDO) | 1 streaming | exportar / escanear puro |
El caso estrella: exportar sin morir
La exportación CSV del capítulo 28 MVC cargaba TODO antes de escribir. Versión streaming:
$out = fopen('productos_full.csv', 'w');
fputcsv($out, ['id', 'nombre', 'precio', 'stock']);
foreach (Producto::orderBy('id')->cursor() as $p) {
fputcsv($out, [$p->id, $p->nombre, $p->precio, $p->stock]);
}
fclose($out); // millones de filas, memoria planaPuntos clave
- chunkById pagina por LLAVE: adiós OFFSET profundo, costo constante.
- lazy() = tandas cómodas; cursor() = un viaje, memoria mínima.
- Escribir mientras lees → chunkById. Exportar → cursor().
- with() dentro de cursor() re-inventa el N+1: no lo hagas.
30 · Bajo el capó
Avanzado ~15 minCierra la Parte V levantando el capó: ver el SQL exacto que Eloquent fabrica, sus bindings, y lo que MariaDB REALMENTE hace con ellos — EXPLAIN, índices trabajando y el N+1 delatándose solo en el log. De consumidor del ORM a conductor.
- Inspeccionar SQL + bindings con toSql() y getBindings().
- Leer EXPLAIN: type ALL vs ref y el índice del esquema en acción.
- Crear un índice para un WHERE frecuente (idempotente) y detectar N+1 en logs.
El SQL desnudo
<?php
require __DIR__ . '/boot.php';
require_once __DIR__ . '/modelos.php';
$q = Pedido::where('estado', 'PAGADO')->where('total', '>', 100);
echo $q->toSql(), "\n";
print_r($q->getBindings());toSql() muestra la plantilla; getBindings() los valores que llenarán los ? — la misma separación que dominaste con PDO preparado, respetada por Eloquent EN TODA consulta de este manual: ningún dato jamás se concatenó al SQL, todo viajó como binding desde el día uno. Cuando una consulta «no filtra lo que debería», este par es tu lupa de aumento.
EXPLAIN: el plan del motor
$plan = DB::select('EXPLAIN SELECT * FROM pedidos WHERE estado = ?', ['PAGADO']);
printf("%-6s | %-20s | %s\n", 'type', 'key', 'rows');
foreach ($plan as $r) {
printf("%-6s | %-20s | %s\n", $r->type, $r->key ?? 'NULL', $r->rows);
}Ahí está el índice idx_pedidos_estado que declaramos en el esquema
(capítulo 8) cobrando su sueldo: type ref significa búsqueda directa vía
índice; key dice cuál usó; rows es la estimación de filas a tocar — 20 PAGADOS, clavado con
la semilla. El enemigo es ALL: escaneo completo de la tabla. La escalera de
type, de mejor a peor: system > const > eq_ref > ref > range > index > ALL.
Si una query crítica vive en ALL, tiene trabajo pendiente.
Índice nuevo para WHERE frecuente
// 1) medir SIN indice:
$sinIndice = DB::select("EXPLAIN SELECT * FROM productos WHERE activo = 1");
// 2) crearlo de forma re-ejecutable (idempotencia otra vez):
DB::statement('DROP INDEX IF EXISTS idx_productos_activo ON productos');
DB::statement('CREATE INDEX idx_productos_activo ON productos (activo)');
// 3) volver a medir:
$conIndice = DB::select("EXPLAIN SELECT * FROM productos WHERE activo = 1");
printf("antes: %s / %s\n",
$sinIndice[0]->type,
$sinIndice[0]->key ?? 'NULL');
printf("despues: %s / %s (%d filas estimadas)\n",
$conIndice[0]->type, $conIndice[0]->key, $conIndice[0]->rows);Treinta y seis activos — los no-múltiplos-de-9 del seed. Dos lecciones de ingeniería: el DROP IF EXISTS hace el script seguro de re-ejecutar, y los índices NO son gratis — aceleran SELECT pero cobran peaje en cada INSERT/UPDATE/DELETE. Se crean por MEDICIÓN (slow query log, getQueryLog), jamás por fe.
El N+1 se delata solo
DB::connection()->enableQueryLog();
foreach (Pedido::all() as $p) {
$x = $p->cliente->nombre; // el loop culpable
}
$repeticiones = collect(DB::connection()->getQueryLog())
->groupBy('query')
->map->count()
->sortDesc();
$repeticiones->each(fn ($n, $sql) => printf("%dx %s\n", $n, $sql));El detector perfecto: agrupa el log por sentencia y cuenta. Cincuenta repeticiones idénticas gritan N+1 sin que nadie abra el código. Esa misma idea — agrupar consultas repetidas y alertar sobre umbrales — es lo que Laravel Debugbar muestra en desarrollo y lo que monitores de producción vigilan solos.
Puntos clave
- toSql() + getBindings(): plantilla y valores, tu lupa ante rarezas.
- EXPLAIN type: ref usa índice; ALL escanea todo. Escalera memorizable.
- Índices por medición, idempotentes, sabiendo su peaje en escrituras.
- groupBy(query) + count sobre el log = detector automático de N+1.
31 · De standalone a Laravel
Intermedio ~13 minLa promesa de la portada se cobra aquí: todo lo que dominaste standalone corre idéntico dentro del framework completo. Eloquent no cambia; lo que cambia es quién le prepara el escenario. Este capítulo es el mapa de traducción.
- Identificar qué aporta Laravel alrededor: rutas, contenedor y configuración.
- Sustituir boot.php por config/database.php + .env.
- Comprobar la portabilidad total de tus modelos.
boot.php se jubila
Nuestro Capsule Manager configuraba host, base, charset y driver A MANO en cada proyecto. Laravel hace lo mismo — solo que leyéndolo de archivos de configuración y variables de entorno:
<?php
// config/database.php (dentro de un proyecto Laravel)
return [
'connections' => [
'mysql' => [
'driver' => 'mysql',
'host' => env('DB_HOST', '127.0.0.1'),
'database' => env('DB_DATABASE', 'tienda_orm'),
'username' => env('DB_USERNAME'),
'password' => env('DB_PASSWORD'),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
],
],
];Reconoce cada línea: driver mysql para MariaDB, utf8mb4 «no negociable» del capítulo 7, la base tienda_orm del 8. Las credenciales migran al archivo .env (que NO va al repositorio — eco de las claves fuera del código que ya practicabas en el MVC).
El mismo modelo, sin cambios
<?php
// app/Models/Pedido.php — identico a nuestro modelos.php,
// solo cambia el namespace de casa:
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Pedido extends Model
{
protected $fillable = ['cliente_id', 'total', 'estado'];
public function cliente()
{
return $this->belongsTo(Cliente::class);
}
public function scopePagados($query)
{
return $query->where('estado', 'PAGADO');
}
}Copiaste tus modelos del laboratorio al framework y FUNCIONAN: mismas relaciones, mismos scopes, mismos casts. La ruta que los consume se lee sola:
// routes/web.php
Route::get('/clientes/{cliente}', function (App\Models\Cliente $cliente) {
return $cliente->load('pedidos'); // route model binding + eager loading
});Laravel resuelve el parámetro {cliente} buscando el modelo por id, inyecta el objeto (listo el findOrFail del capítulo 15, ahora automático) y tú encadenas lo aprendido.
Mapa de traducción
| Standalone (este manual) | Laravel | Nota |
|---|---|---|
| composer require illuminate/database | viene de fábrica | cero instalación |
| boot.php + Capsule | config/database.php + .env | misma info, otro lugar |
| DB::table()->... | idéntico | mismo builder |
| modelos.php global | app/Models con namespace | solo autoloading PSR-4 |
| php scripts/dashboard.php | rutas HTTP + controladores | y comandos artisan |
| require manual de clases | contenedor de servicios | inyección automática |
Fíjate en lo que NO aparece en la columna derecha: conceptos nuevos de ORM. El framework aporta PLUMBING — rutas, contenedor, configuración, colas — pero tu conocimiento de relaciones, eager loading, transacciones y streaming viaja intacto. Por eso elegimos aprender el ORM ANTES que el framework.
Puntos clave
- Laravel añade infraestructura alrededor; Eloquent es el mismo motor.
- Credenciales viven en .env; la config declara estructura.
- Tus modelos standalone son portables tal cual (solo namespace).
- Aprender el ORM primero = el framework se vuelve ensamblaje.
32 · Migraciones y seeders
Avanzado ~15 minNuestro php_03_tienda_orm.sql es un script monolítico: reconstruye TODO o nada. En equipos eso explota — ¿cómo avanza el esquema en producción SIN borrar datos? La respuesta Laravel: migraciones versionadas y seeders reproducibles. Y nuestras convenciones los hacen triviales.
- Escribir Schema::create() espejando nuestro SQL tabla por tabla.
- Entender el historial versionado de artisan migrate.
- Portar el seed determinista del capítulo 9 a un Seeder.
El SQL traducido a Blueprint
<?php
// database/migrations/2026_08_23_000003_create_pedidos_table.php
Schema::create('pedidos', function (Blueprint $table) {
$table->id(); // BIGINT UNSIGNED PK id
$table->foreignId('cliente_id')->constrained()
->restrictOnDelete(); // FK RESTRICT (cap. 8)
$table->decimal('total', 10, 2)->default(0); // DECIMAL, NUNCA float
$table->enum('estado', ['REGISTRADO', 'PAGADO', 'ANULADO'])
->default('REGISTRADO');
$table->index('estado'); // idx_pedidos_estado
$table->timestamps(); // created_at/updated_at
});Compara con el CREATE TABLE del capítulo 8: decisión por decisión, la misma. Ni una convención inventada de cero — las que Eloquent deduce gratis (id, plural snake_case, timestamps) son exactamente las que adoptamos desde el principio. Migrar es TRADUCIR, no reaprender.
El historial que el .sql no tenía
Cada migración es UN paso forward con fecha; la tabla migrations recuerda cuáles corrieron.
Mañana agregas create_descuentos_table — y tus compañeros solo ejecutan migrate
para recibir EL DELTA, sin recrear la base entera. Es control de versiones para el esquema:
git commit, pero para tablas. Y migrate:fresh --seed es nuestro DROP DATABASE +
seed de siempre, ahora con nombre respetable.
El seeder: módulos PHP en vez de CTE
<?php
// database/seeders/TiendaSeeder.php
public function run(): void
{
foreach (range(1, 30) as $n) {
Cliente::create([
'nombre' => sprintf('Cliente %03d', $n),
'email' => "cliente{$n}@correo.pe",
]);
}
$categorias = ['Cafe', 'Te', 'Chocolate', 'Miel', 'Accesorio'];
foreach (range(1, 40) as $n) {
Producto::create([
'nombre' => $categorias[intdiv($n - 1, 8)] . ' ' . (($n - 1) % 8 + 1),
'precio' => number_format(
5 + ($n * 37) % 90 + (($n * 13) % 100) / 100, 2, '.', ''
),
'stock' => 5 + ($n * 17) % 60,
'activo' => $n % 9 !== 0,
]);
}
}Los mismos módulos del CTE del capítulo 9, reescritos en PHP nativo: ELT/DIV/MOD → índice de arreglo + intdiv + resto. Misma fórmula, mismo resultado determinista en cualquier máquina — la semilla deja de ser texto SQL y pasa a ser código versionado, testeable, compartido.
Puntos clave
- Schema::create espeja nuestro SQL línea a línea gracias a convenciones.
- migrate aplica SOLO lo pendiente: deltas versionados para equipos.
- Seeders portan el determinismo del CTE a código de repositorio.
- Script único para laboratorio; migraciones para producción viva.
33 · Eloquent en producción
Avanzado ~15 minEl laboratorio terminó; empieza la vida real, donde hay usuarios concurrentes, datos de millones y ningún var_dump a la vista. Este capítulo consolida las reglas de casa del manual en una checklist de producción — cada punto ya lo viviste, aquí solo se firman.
- Recorrer la checklist de despliegue con su capítulo de origen.
- Memorizar la tabla de errores típicos y sus antídotos.
- Firmar las reglas consolidadas del laboratorio.
Checklist antes del despliegue
| # | Verificación | Herramienta | Origen |
|---|---|---|---|
| 1 | Queries críticas fuera de type ALL | EXPLAIN + índices medidos | cap. 30 |
| 2 | Cero N+1 en listados calientes | groupBy(query) sobre el log | caps. 20–21 |
| 3 | Escrituras multi-tabla atómicas | DB::transaction(closure) | cap. 27 |
| 4 | Dinero en DECIMAL/casts correctos | $casts decimal:2, sin float | caps. 8 y 25 |
| 5 | Volumen por lotes/streaming | insert() + chunk/lazy/cursor | caps. 28–29 |
| 6 | Esquema versionado en repo | migraciones + seeders | cap. 32 |
Errores típicos: el antídoto ya lo tienes
| Síntoma en producción | Diagnóstico | Antídoto (visto en) |
|---|---|---|
| «El listado tarda segundos» | N+1 invisible | with()/lazy() — caps. 20, 21, 29 |
| «Los totales no cuadran centavos» | float tocando dinero | DECIMAL string + bc* — cap. 25 |
| «Pedidos con detalles a medias» | escritura sin atomicidad | transacción — cap. 27 |
| «La página 9000 cuelga» | OFFSET profundo | chunkById/keyset — caps. 11, 29 |
| «No corren mis observers al importar» | insert() bypassa eventos | saberlo elegir — cap. 28 |
| «En mi PC volaba» | latencia multiplicando viajes | presupuesto de consultas — cap. 23 |
Ninguna fila de esas tablas exige conocimiento nuevo: son los capítulos convertidos en reflejos. La madurez con un ORM no es conocer cien métodos; es reconocer estos seis síntomas ANTES de que el usuario los descubra.
Reglas de casa consolidadas
REGLAS DEL LABORATORIO ELOQUENT — edicion de produccion 1. Jamas consultar dentro de un loop. Sospechar, medir, curar con with(). 2. Escritura multi-tabla = transaccion. Siempre. Sin excepciones. 3. Entidades vivas = modelo; volumenes/reportes = builder. 4. Dinero NUNCA float: DECIMAL entra y sale como string. 5. Condicion de negocio repetida dos veces = scope. 6. Indice se crea por medicion (EXPLAIN/log), jamas por fe. 7. Presupuesto de consultas declarado ANTES de codificar. 8. Volumen masivo: insert() sabiendo su bypass, o streaming. 9. Presentar es accessor; normalizar es mutator. Sin mezclarlos. 10. Ids son identificadores, no contadores de negocio.
Puntos clave
- Toda la checklist proviene de capítulos ya dominados: nada nuevo, todo firme.
- Los errores típicos tienen antídoto nombrado y capítulo de origen.
- Producción = laboratorio + disciplina de vigilancia continua.
- Las diez reglas caben en una tarjeta junto al monitor.
34 · Mapa final y rutas
Básico ~12 minÚltima parada: el mapa de lo recorrido y los caminos que salen de aquí. Cerramos con el examen de graduación — un mini sistema completo donde todo lo aprendido se usa a la vez.
- Repasar las seis partes en una tabla-resumen.
- Elegir tu ruta de continuación informada.
- Resolver el examen de graduación sin ayuda.
El mapa del viaje
| Parte | Caps | Lo que te llevas |
|---|---|---|
| I · Por qué un ORM | 1–5 | Active Record vs Data Mapper; ventajas honestas y costos reales |
| II · Instalación standalone | 6–10 | Composer, Capsule, utf8mb4 y la base determinista |
| III · CRUD básico | 11–17 | Builder completo, modelos, dirty tracking, paginación propia |
| IV · Relaciones | 18–23 | belongsTo/hasMany, N+1 medido y curado, whereHas, dashboard auditado |
| V · Avanzado | 24–30 | Scopes, casts/accessors, colecciones, transacciones, streaming, EXPLAIN |
| VI · Puente a Laravel | 31–33 | Migraciones, seeders y la checklist de producción firmada |
Rutas de continuación
Documentación oficial Eloquent/Laravel: la ruta directa si vienes al framework — ahora cada sección (Eloquent ORM, Migrations, Seeding) te resultará repaso nombrado. Doctrine: el Data Mapper contrastante de la Parte I — verlo después de dominar Active Record es entender POR QUÉ existen dos escuelas, no solo cómo difieren. Testing con SQLite en memoria: tus modelos corriendo contra una base que nace y muere por test — la Capsule que montaste en el capítulo 7 apunta a SQLite cambiando tres líneas.
Examen de graduación
EXAMEN: mini-sistema CLI de pedidos (sin mirar el manual)
1. Boot standalone + base propia (3 tablas relacionadas).
2. Seed determinista de 100+ registros (modulo o CTE).
3. Comando "vender": crea pedido + detalles EN TRANSACCION,
recalculando el total desde los detalles. Jamas float.
4. Comando "reporte": top clientes SIN N+1, facturado por
estado, productos dormidos. Presupuesto declarado.
5. Exportacion CSV streaming del reporte.
6. Auditoria final: conteo de consultas impreso.
Aprobado = los seis puntos + cero consultas dentro de loops.Si lo resuelves sin volver atrás, no aprendiste Eloquent: PIENSAS con él. Y ese era el objetivo real del manual.
Hasta la próxima
Tres manuales PHP juntos ya: el lenguaje, el MVC artesanal y este ORM. La escalera está completa — del echo al framework pasando por construir cada pieza a mano para saber qué abstraes. Cuando toques Laravel por primera vez y sientas que ya conocías sus secretos, acuérdate: fue aquí, con una tiendita de café llamada tienda_orm.
Puntos clave
- Seis partes, una progresión: del «por qué» al «en producción».
- Doctrine y testing en memoria son las rutas de profundización naturales.
- El examen integra CRUD+relaciones+transacción+reporte sin N+1.
- Aprender el ORM antes que el framework convierte a este en ensamblaje.