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.

Descargar php_03_tienda_orm.sql disponible desde el capítulo 8
34 capítulos Bootstrap 5.3 Modo claro / oscuro Optimizado para móvil Prerrequisito: manual MVC
34
Capítulos
150+
Ejemplos de código
3
Niveles: básico a experto
3
Requisitos: PHP 8, Composer, MariaDB
Cómo usar este tutorial: sigue los capítulos en orden (el índice está en el menú si lees desde el móvil). Cada capítulo agrega una pieza al laboratorio tienda_orm (dominio Pedidos). Requiere los manuales PHP 8.5 y MVC: heredamos su POO, su contenedor y — sobre todo — su repositorio PDO artesanal, al que le tomamos el relevo.

1 · El costo oculto del SQL suelto

Básico ~15 min

Este 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',
};
Nada de esto es un error nuestro: es el costo NATURAL de hablar dos idiomas (objetos PHP / tablas relacionales) sin traductor. La pregunta honesta no es «¿escribí mal el repo?» sino «¿merece la pena automatizar esta traducción?». Los próximos tres capítulos responden con datos.

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:

  1. Sentencias preparadas SIEMPRE, sin excepciones humanas posibles.
  2. Transacciones accesibles para pedido+detalles (eco cap. 20).
  3. El dominio puede seguir siendo readonly fuera de la capa de persistencia.
  4. 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 min

Object-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 interfacesNo existe el concepto
Identidad = instancia (===)Identidad = clave primaria
Tipos ricos: DateTimeImmutable, enumDATETIME, 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
CriterioActive RecordData Mapper
¿Dónde está el SQL?Dentro del modelo (heredado)En repositorios/managers separados
Curva inicialBaja — 10 minutos al primer save()Alta — mapeos XML/atributos
Entidad «limpia»No: hereda de Model con magiaSí: POO pura sin dependencias
Rapidez CRUDImbatibleceremonial
Ejemplos PHPEloquent, Yii ARDoctrine, 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.

Precisión terminológica: «Eloquent» son en realidad DOS cosas: el Query Builder (constructor fluido de SQL, capa baja) y los modelos Active Record (capa alta sobre el builder). Instalamos ambos; este manual sube por las capas en ese orden — builder primero (Parte III), modelos después.
Eco Kotlin: si programaste Exposed o Room en la serie móvil, ya viviste este debate. Room es Data Mapper (@Entity + DAO separado); Exposed flirtea con DSL-builder como nuestro Query Builder. Mismas escuelas, otro lenguaje.

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 min

Los 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 suelto

El 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.
# Resumen ejecutable de esta parte: # 1 linea builder vs ~6 lineas PDO ....... CRUD diario # bindings estructurales ................. inyeccion casi imposible # condiciones acumulables ................ fin del WHERE 1=1 # casts automaticos ...................... desdeFila() jubilado # nueva columna = 1 cambio ............... mantenimiento plano
Honestidad de método: estas cinco ventajas son reales PERO se concentran en el CRUD cotidiano (el 80% del volumen de código). El otro 20% — reportes complejos, bulk masivo, tuning fino — es territorio donde el balance se inclina distinto. Eso es exactamente el contenido del próximo capítulo.

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 min

Un 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 BD

El 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

EscenarioHerramienta correcta
CRUD web cotidiano, formularios, listadosEloquent — imbatible
Reportes analíticos complejosSQL 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 YAEloquent con revisión de toSql
# La postura de este manual, sin pelos en la lengua: # Eloquent NO reemplaza tu conocimiento SQL: lo multiplica. # Quien no sabe SQL escribe desastres fluentes. # Quien SI sabe SQL escribe CRUD en una linea y reportes donde toca.
Eco del manual MVC: nuestra regla «el total jamás llega del cliente» tiene gemela aquí: «la confianza en el ORM jamás sustituye mirar el SQL generado». Ambas se verifican igual — mecánicamente.

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 min

Ventajas 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

# Regla de casa para proyectos PHP modernos: # # / Reportes analiticos, window functions \ DB::select (SQL puro) # | Bulk masivo plano (> 10k filas) | insert() sin modelos # |-------------------------------------------| # | CRUD web, formularios, listados, | # | relaciones cotidianas, admin panels | ELOQUENT (modelos) # |-------------------------------------------| # \ Prototipos desechables, scripts CLI / Query Builder solo

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.
# El mapa de las proximas 25 capitulos: # Parte II .... instalacion + base lista para practicar # Parte III ... builder y modelos: el CRUD completo # Parte IV .... relaciones y la caceria del N+1 # Parte V ..... scopes, casts, transacciones, volúmenes # Parte VI .... el mismo codigo dentro de Laravel
Por qué standalone y NO directo en Laravel: porque el encargo es «el ORM antes que el framework». Aprendiendo Eloquent desnudo ves SU lógica sin confundirla con la magia del framework — y cuando llegues a Laravel (cap. 31), reconocerás qué aporta cada capa.

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 min

Composer 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)

# Linux (Debian/Ubuntu): sudo apt install composer # Verificar en cualquier sistema: composer --version # Composer version 2.8.x 2026-01-...

En Windows: instalador oficial desde getcomposer.org (detecta tu PHP). Si ya usaste Composer para el manual MVC, tienes todo.

El proyecto laboratorio

# Crear la carpeta del manual: mkdir tienda_orm_lab && cd tienda_orm_lab # Declarar el proyecto (crea composer.json minimo): composer init --no-interaction --name="webcode/tienda-orm-lab"

Los cuatro comandos sagrados

# 1. Agregar una libreria (descarga Y registra): composer require illuminate/database # 2. Instalar TODO lo declarado (clonando el repo o tras git pull): composer install # 3. Actualizar a las ultimas versiones permitidas: composer update # 4. Que es lo que tengo y quien depende de quien? composer show --tree

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 install desde el lock.
# .gitignore del laboratorio vendor/

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/" }
    }
}
# Tras tocar [autoload] en composer.json, regenerar el mapa: composer dump-autoload
Error clásico: editar composer.json a mano y olvidar dump-autoload. Síntoma: «Class App\Pedido not found» aunque el archivo exista. El autoloader no adivina: lee el mapa generado.
Eco de series anteriores: npm/package-lock/node_modules = Composer/composer.lock/vendor. Cargo/Cargo.toml igual. La industria convergió en el mismo diseño — aprenderlo aquí te sirve en todas partes.

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 min

El 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

composer require illuminate/database illuminate/events # Nota: composer resolvera dependencias hermanas automaticamente # (illuminate/support, container, pagination...) — es normal, es la familia.

¿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 listos

Las 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 — permite Capsule::table() y DB::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'));
php pulso.php
Conectado a MariaDB 11.4.3-MariaDB Tablas: Array ( [0] => stdClass Object ( [Tables_in_tienda_orm] => clientes ) ... )

(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']);
utf8mb4 NO es negociable: el utf8 clásico de MySQL/MariaDB solo guarda 3 bytes por carácter — «José» pasa, 🚚 no. utf8mb4 + collation unicode es el estándar actual; Laravel lo exige por defecto. Configúralo aquí y olvídate de caracteres rotos para siempre.

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 min

Llega 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

# Desde la carpeta del proyecto: mariadb -u root -p < php_03_tienda_orm.sql # Verificacion inmediata: mariadb -u root -p -e "USE tienda_orm; SHOW TABLES; SELECT COUNT(*) FROM clientes;"
Diagrama Entidad-Relación (ERD) · Tienda ORM (4 tablas) Abrir SVG completo
Modelo Entidad-Relación · Base de Datos Tienda ORM (4 tablas) Dominio Web MVC, Eloquent Standalone, CodeIgniter y MongoDB (tienda_orm) 1 N 1 N 1 N clientes PK id BIGINT UNSIGNED nombre VARCHAR(100) UQ email VARCHAR(150) created_at TIMESTAMP updated_at TIMESTAMP pedidos PK id BIGINT UNSIGNED FK cliente_id BIGINT UNSIGNED total DECIMAL(10,2) estado ENUM timestamps created/updated productos PK id BIGINT UNSIGNED nombre VARCHAR(120) precio DECIMAL(10,2) stock / activo INT / TINYINT(1) timestamps created/updated pedido_detalles (Relación N:M) PK id BIGINT UNSIGNED FK pedido_id CASCADE FK producto_id RESTRICT cantidad INT UNSIGNED precio_unitario DECIMAL(10,2) hist.

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ónQuién la usa
PK autoincremental llamada idfind(17), save(), lastInsertId interno
Tablas plural snake_case (pedido_detalles)Modelo PedidoDetalle → tabla adivinada sola
FK cliente_id, producto_idbelongsTo infiere la columna del método
created_at/updated_attimestamps 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.
# El grafo resultante: # clientes 1 ────< pedidos 1 ────< pedido_detalles >──── 1 productos # (un cliente muchos pedidos) (un pedido muchas lineas) (cada linea un producto)
Descarga: el archivo vive junto a este manual — botón «Descargar php_03_tienda_orm.sql» en la portada. Es idempotente prudente: hace DROP DATABASE IF EXISTS antes de crear, así puedes re-ejecutarlo cuantas veces quieras volver al punto cero.

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 min

El 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 puro

Tres 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 + 1Estado (n-1 MOD 5)
18PAGADO
215PAGADO
322REGISTRADO
56ANULADO

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 -e " USE tienda_orm; SELECT 'clientes' t, COUNT(*) c FROM clientes UNION ALL SELECT 'productos', COUNT(*) FROM productos UNION ALL SELECT 'pedidos', COUNT(*) FROM pedidos UNION ALL SELECT 'detalles', COUNT(*) FROM pedido_detalles;"
t c clientes 30 productos 40 pedidos 50 detalles 100
# Y la prueba de coherencia comercial: mariadb -u root -p -e " USE tienda_orm; SELECT COUNT(*) FROM pedidos WHERE total = 0;"
COUNT(*) 0
Ejercicio previo a seguir: re-ejecuta el script completo (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 min

Laboratorio 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]);
php primera.php
Total: 30 Array ( [id] => 1 [nombre] => Cliente 001 [email] => cliente1@correo.pe [created_at] => 2026-08-23 19:14:02 [updated_at] => 2026-08-23 19:14:02 )

¿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']);
}
Chocolate 7 S/ 93.72 Cafe 3 S/ 88.44 Te 5 S/ 84.11 Miel 8 S/ 79.63 Accesorio 2 S/ 75.20

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 mismo

Serán tus dos mejores amigos durante todo el manual: ante cualquier duda, envuelve la consulta en dd(...) y mira su contenido crudo.

# El habito de verificacion (eco del cap. 4): var_dump( DB::table('pedidos')->where('total', '>', 100)->toSql() );
string(48) "select * from `pedidos` where `total` > ? limit 5"

El placeholder ? confirma los bindings; las comillas invertidas son el dialecto MariaDB. Mirar toSql() cada tanto mantiene tu SQL vivo bajo la capa fluida.

¿Y count? Para preguntas que devuelven UN número, el builder tiene atajos: ->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 min

Una 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étodoSQL generadoCuándo usarlo
whereIn(col, [...])col IN (?, ?, ?)conjuntos cerrados (estados, ids)
whereBetween(col, [a,b])col BETWEEN ? AND ?rangos numéricos/fechas
whereNull / whereNotNullcol IS NULLcampos 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 totales
# Verifica siempre que sospechas (el habito toSql): var_dump( DB::table('pedidos') ->whereIn('estado', ['PAGADO','ANULADO']) ->toSql() );
"select * from `pedidos` where `estado` in (?, ?)"

Tres placeholders para dos estados… no: DOS placeholders, exactamente los elementos del arreglo. Los bindings se generan solos y en orden — imposible desincronizarlos.

skip() profundo es lento: OFFSET 50000 obliga a MariaDB a contar 50 000 filas antes de empezar. Para tablas grandes se pagina por cursor (->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 min

Leer 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.
update() sin where = TODA la tabla modificada. El builder no te salva de ti: ejecuta lo que le digas. Regla de casa nueva: ningún update/delete sale de tus dedos sin su where inmediato encima — ni «solo esta prueba».

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

php -r " require 'boot.php'; use Illuminate\Database\Capsule\Manager as DB; \$id = DB::table('clientes')->insertGetId( ['nombre'=>'Prueba', 'email'=>'p@x.pe', 'created_at'=>now(), 'updated_at'=>now()] ); echo \"creado \$id\n\"; DB::table('clientes')->where('id', \$id)->update(['nombre'=>'Prueba editada']); echo DB::table('clientes')->where('id', \$id)->value('nombre'), "\n"; DB::table('clientes')->where('id', \$id)->delete(); echo 'quedan ', DB::table('clientes')->where('email','p@x.pe')->count(), "\n"; "
creado 31 Prueba editada quedan 0

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 min

Aquí 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);
}
#48 Cliente 008 S/ 312.40 #47 Cliente 015 S/ 89.90 #46 Cliente 001 S/ 204.15 #45 Cliente 022 S/ 67.80

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']);
Facturado S/ 5837.55 | Max S/ 492.10 | Prom S/ 121.62 | 100 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";
7 clientes frios

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.

# Verificacion del SQL generado (habito permanente): var_dump( DB::table('clientes') ->leftJoin('pedidos','pedidos.cliente_id','=','clientes.id') ->whereNull('pedidos.id') ->toSql() );
"select * from `clientes` left join `pedidos` on `pedidos`.`cliente_id` = `clientes`.`id` where `pedidos`.`id` is null"

Método por método, el SQL aparece espejado — si alguna vez dudas qué estás pidiendo, toSql() nunca miente.

Ejercicio: construye «productos más vendidos» — join entre pedido_detalles y productos, SUM(cantidad) agrupado por producto, orden descendente, top 3. Son seis líneas encadenadas; la solución mental ya la tienes completa.

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 min

El 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áticaReglaAquí resulta
Tablanombre de clase en plural snake_caseclientes
Llave primariaid entero autoincrementalid
Timestampsmantener created_at/updated_atactivos

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);
php modelos.php
Cliente Cliente 001 <cliente1@correo.pe> int(1)

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";
}
string(10) "Collection" string(8) "Cliente" 1: cliente1@correo.pe 2: cliente2@correo.pe 3: cliente3@correo.pe

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 humano
string(22) "Carbon\CarbonImmutable" 23/08/2026 19:14 hace 3 dias

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

¿Cuándo modelo y cuándo builder? Regla práctica: si trabajas CON UNA ENTIDAD viva (leer, editar, borrar una cosa concreta) → modelo. Si haces operaciones MASIVAS o reportes → builder (cap. 16 cierra esta decisión con tabla).

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 min

El 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',
]);
php crear.php
PHP Fatal error: Uncaught Illuminate\Database\Eloquent\MassAssignmentException: Add fillable property to allow mass assignment on [Cliente]!

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
}
php crear.php && php -r "require 'boot.php'; echo Cliente::latest('id')->value('nombre'), PHP_EOL;"
Distribuidora Sur SAC

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 automaticamente
nuevo id: 32

Esta 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";
NULL cliente inexistente: responder 404 Café Central SAC

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.

# Verificacion de integridad tras los inserts: php -r "require 'boot.php'; echo 'total clientes: ', Cliente::count(), PHP_EOL; echo 'timestamps ok?: '; var_dump(Cliente::find(31)->created_at != null);"
total clientes: 32 timestamps ok?: bool(true)

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 min

La 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'));
php editar.php
precio actual: 93.72 bool(true) bool(false) bool(true) bool(false)

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 MariaDB

Ojo: 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 solos
# Verificacion de la cascada declarativa: php -r "require 'boot.php'; echo 'detalles del pedido 50: ', DB::table('pedido_detalles')->where('pedido_id', 50)->count(), PHP_EOL;"
detalles del pedido 50: 0

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

Borrado físico vs lógico: delete() ejecuta DELETE real. El ecosistema ofrece SoftDeletes (borrado lógico con deleted_at) — lo mencionamos para que sepas que existe; nuestro laboratorio mantiene borrado duro y FKs defensivas, igual que el sistema MVC.

La decisión final: modelo vs builder

SituaciónHerramientaPero ¿por qué?
Editar un registro concreto desde un formularioModelo + save()dirty tracking + eventos
Alza de precios del 5% a 4 000 productosBuilder update()1 UPDATE, cero hidratación
Cargar una orden completa para mostrarModelo find()objetos, relaciones (Parte IV)
Reporte agregado multi-tablaBuilder join/sumel 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 min

El 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);
}
php catalogo.php
Pagina 3 de 4 (40 productos) 25. Miel 1 S/ 30.25 26. Miel 2 S/ 67.38 27. Miel 3 S/ 14.51 28. Miel 4 S/ 51.64 29. Miel 5 S/ 88.77 30. Miel 6 S/ 35.90 31. Miel 7 S/ 72.03 32. Miel 8 S/ 19.16 33. Accesorio 1 S/ 56.29 34. Accesorio 2 S/ 93.42 35. Accesorio 3 S/ 40.55 36. Accesorio 4 S/ 77.68

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 vacia
int(99) bool(true)

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

Honestidad de standalone: la famosa ->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 min

Empieza 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 extra
php relacion.php
Cliente 008 cliente8@correo.pe

El 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";
string(48) "Illuminate\Database\Eloquent\Relations\BelongsTo" string(7) "Cliente" select * from `clientes` where `clientes`.`id` = ? limit 1

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.

Advertencia visual: mira este bucle inocente — 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 min

Si 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ónDónde vive la FKLectura
$pedido->cliente  (belongsTo)pedidos.cliente_idmuchos → uno
$cliente->pedidos  (hasMany)pedidos.cliente_iduno → 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 base
pedido #1 PAGADO S/ 321.05 pedido #31 PAGADO S/ 238.65 total: 2

La 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);
}
Cliente 002 -> 2 pedido(s) Cliente 004 -> 2 pedido(s) Cliente 006 -> 2 pedido(s)

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";
pedido #51 para el cliente 8

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 min

El 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";
php n1.php
Cliente 008 Cliente 015 Cliente 022 ...(47 lineas mas)... consultas totales: 51

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 server51 viajesSensación del usuario
0.5 ms (localhost)~26 msni se entera
5 ms (misma región cloud)~255 msaceptable, pero innecesario
50 ms (cross-region / wifi malo)~2.6 s«¿se colgó?»
200 ms (3G marginal)~10 susuario 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);
lazy: 28.4 ms <- localhost; en red real multiplica x100
Cuidado con el log: getQueryLog() acumula TODAS las consultas en memoria. Perfecto para auditar un request como aquí; peligroso en procesos largos (10 000 consultas grabadas = RAM pagada). En Laravel existe DB::whenQueryingForLongerThan() para vigilar esto por request. Regla de laboratorio: activar el log, medir, DESACTIVAR.
Regla de casa: JAMÁS consultar dentro de un loop. Si ves una flecha de relación accedida dentro de un foreach sobre una Colección grande, sospecha primero, mide después. La cura ya tiene nombre — 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 min

Una 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";
php eager.php
Cliente 008 Cliente 015 Cliente 022 ...(47 lineas mas)... consultas totales: 2

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 5

load() 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";
20 pedidos pagados, con sus detalles grandes

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 min

with() 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";
php filtros.php
select * from `clientes` where exists (select * from `pedidos` where `clientes`.`id` = `pedidos`.`cliente_id` and `estado` = ?) 12 clientes tienen al menos un pago

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";
con pedidos: 30 sin pedidos: 0

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";
}
18 clientes sin ningun cobro: Cliente 001 Cliente 002 Cliente 004 Cliente 006 Cliente 007

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";
via joins: 18 via withCount: 18

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 min

Ejercicio 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.
Presupuesto declarado: 6 consultas como máximo. Antes de escribir una línea decidimos cuántas idas al servidor se permiten. Sin ese número, el N+1 entra sin ser invitado; con él, la auditoría final es binaria: pasaste o no pasaste.

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));
}
PAGADO 20 pedidos S/ 9,067.55 REGISTRADO 20 pedidos S/ 4,963.75 ANULADO 10 pedidos S/ 1,179.20

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);
}
Cliente 002 (2 pedidos) Cliente 004 (2 pedidos) Cliente 006 (2 pedidos) Cliente 007 (2 pedidos) Cliente 008 (2 pedidos)

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);
}
#10 Cliente 011 ANULADO S/ 107.93 #9 Cliente 004 REGISTRADO S/ 343.32 #8 Cliente 027 REGISTRADO S/ 141.37 #7 Cliente 020 PAGADO S/ 687.33 #6 Cliente 013 PAGADO S/ 358.25 #5 Cliente 006 ANULADO S/ 97.63 #4 Cliente 029 REGISTRADO S/ 309.42 #3 Cliente 022 REGISTRADO S/ 296.12 #2 Cliente 015 PAGADO S/ 640.48 #1 Cliente 008 PAGADO S/ 321.05

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";
}
8 productos sin venderse EN LO COBRADO: Cafe 2 Cafe 7 Te 4 Chocolate 1 Chocolate 6 Miel 3 Miel 8 Accesorio 5

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');
presupuesto: 6 | usado: 5 | DENTRO DE PRESUPUESTO

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 min

Cuenta 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";
php scopes.php
pagados: 20 pagados entre S/ 100 y S/ 300: 4 16, 21, 31, 36 del cliente 8: 2

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 sitiosPedido::pagados()el modelo
whereBetween('total', [...]) repetidorangoTotal(100, 300)el modelo
where('cliente_id', $id) dispersodeCliente($id)el modelo
criterio nuevo = buscar-y-reemplazarregla nueva = 1 métodonadie 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.

Existen los globales: además de los locales, Eloquent ofrece Global Scopes que se aplican a TODA consulta del modelo (SoftDeletes usa uno para ocultar filas con deleted_at). Poderoso y peligroso — invisible para quien lee. Nuestro laboratorio se queda con locales: explícitos, testables, suficientes.

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 min

Lo 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";
php casts.php
bool(false) string(25) "Illuminate\Support\Carbon" string -> 68.17

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 salvan
bool(false) bool(false)

Ambos 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 guardado
contacto@cafecentral.pe Cafe Central Sac cafe central sac

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

Regla de separación: mutator para NORMALIZAR datos (mayúsculas, espacios, formatos canónicos — el dato cambia); accessor para PRESENTARLO (mayúsculas visuales, formatos locales — el dato intacto). Si un accessor «arregla» datos sucios, el bug sigue vivo en base: usa mutator.

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 min

Cada 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));
php coleccion.php
15, 28 unidades: 8 62.85 + 258.20 = 321.05

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 EloquentMatiz
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";
1 linea(s) grandes el pedido #7 es el mas caro: S/ 687.33 {"PAGADO":20,"REGISTRADO":20,"ANULADO":10}

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:

PreguntaLugar correctoRazón
Total de 500 000 pedidosbuilder: sum('total')el motor suma sin traer filas
Subtotal de LOS detalles ya cargadoscolección: sum(fn)cero consultas extra
Ranking completo ordenadobuilder: orderBy + limitordenar 500 k en PHP = RAM llorando
Reordenar los 20 ya en pantallacolección: sortByDescya 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 min

Un 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);
php atomico.php
pedido #52 con 2 lineas (total 62.64)

(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' : '?!');
capturada: pago sin detalles: operacion invalida pedidos antes: 51 | despues: 51 | 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ó.

Dos nuggets finos: DB::transaction acepta un segundo argumento con el número de REINTENTOS ante deadlock (el servidor te pide reintentar y el método lo hace solo). Y ojo: los AUTO_INCREMENT quemados NO retroceden — un rollback deja huecos en los ids. Los ids son identificadores, no contadores de negocio; si eso te molesta, el problema es del requisito, no del motor.

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.

Regla de casa inamovible: toda escritura multi-tabla va en transacción. Pedido+detalles, transferencia de stock, matrícula+cuotas: SIEMPRE. El día que falte, será el día del ticket más caro de soporte.

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 min

Cargar 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());
php masivo.php
antes: 100 | ahora: 600

Quinientas filas en un solo viaje. La tabla comparativa que cierra toda discusión:

VíaViajes para 500Pasa porIdeal para
create() x 500500eventos, casts, dirty trackingpocas entidades vivas
insert($lote)1nada: SQL directovolumen bruto
insert por chunks de 1005nada, paquetes segurosmiles+ 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";
limpieza: 500 filas del lote eliminadas detalles finales: 100

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.

Límite físico: cada sentencia viaja en un paquete limitado por max_allowed_packet del servidor (default ~16 MB). Lotes de millones de filas NO caben en uno: parte en chunks (siguiente capítulo lo automatiza). Síntoma clásico de pasarse: «Got a packet bigger than 'max_allowed_packet'».

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 min

get() 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);
php streaming.php
4 tandas | stock total 1420 unidades

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";
via lazy: 30 pedidos sobre S/ 300: 22

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.

HerramientaMemoriaViajesTerritorio ideal
get()todas las filas1listas pequeñas/medianas
chunkById(N, fn)N filasN por páginaESCRIBIR mientras lees
lazy()tanda actualvariosleer-comfortable por lotes
cursor()~1 fila (PDO)1 streamingexportar / 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 plana
Trampas conocidas: (1) cursor() + with() NO agrupa eager loading — cada fila dispara SU relación y el N+1 del capítulo 20 regresa disfrazado; para leer relaciones usa lazy(). (2) Mientras iteras cursor()/lazy() la conexión está comprometida; no mezcles escrituras largas ahí dentro — para eso es chunkById. (3) Nunca ordenes dentro de cursor() esperando magia global: ORDER BY se evalúa en esa única consulta, lo cual puede forzar tablas temporales gigantes.

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

Cierra 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());
php capo.php
select * from `pedidos` where `estado` = ? and `total` > ? Array ( [0] => PAGADO [1] => 100 )

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);
}
type | key | rows ref | idx_pedidos_estado | 20

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);
antes: ALL / NULL despues: ref / idx_productos_activo (36 filas estimadas)

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));
50x select * from `clientes` where `clientes`.`id` = ? limit 1 1x select * from `pedidos`

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 min

La 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)LaravelNota
composer require illuminate/databaseviene de fábricacero instalación
boot.php + Capsuleconfig/database.php + .envmisma info, otro lugar
DB::table()->...idénticomismo builder
modelos.php globalapp/Models con namespacesolo autoloading PSR-4
php scripts/dashboard.phprutas HTTP + controladoresy comandos artisan
require manual de clasescontenedor de serviciosinyecció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 min

Nuestro 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

php artisan migrate
2026_08_23_000001_create_clientes_table ............ 12ms DONE 2026_08_23_000002_create_productos_table ........... 9ms DONE 2026_08_23_000003_create_pedidos_table ............. 14ms DONE 2026_08_23_000004_create_pedido_detalles_table ..... 11ms DONE

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.

¿Cuándo cada uno? Script .sql monolítico: laboratorios personales, entregas únicas, restauración rápida. Migraciones: productos vivos, equipos, cualquier esquema que CAMBIARÁ después de nacer. El contenido técnico es el mismo; cambia el ciclo de vida.

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 min

El 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ónHerramientaOrigen
1Queries críticas fuera de type ALLEXPLAIN + índices medidoscap. 30
2Cero N+1 en listados calientesgroupBy(query) sobre el logcaps. 20–21
3Escrituras multi-tabla atómicasDB::transaction(closure)cap. 27
4Dinero en DECIMAL/casts correctos$casts decimal:2, sin floatcaps. 8 y 25
5Volumen por lotes/streaminginsert() + chunk/lazy/cursorcaps. 28–29
6Esquema versionado en repomigraciones + seederscap. 32

Errores típicos: el antídoto ya lo tienes

Síntoma en producciónDiagnósticoAntídoto (visto en)
«El listado tarda segundos»N+1 invisiblewith()/lazy() — caps. 20, 21, 29
«Los totales no cuadran centavos»float tocando dineroDECIMAL string + bc* — cap. 25
«Pedidos con detalles a medias»escritura sin atomicidadtransacción — cap. 27
«La página 9000 cuelga»OFFSET profundochunkById/keyset — caps. 11, 29
«No corren mis observers al importar»insert() bypassa eventossaberlo elegir — cap. 28
«En mi PC volaba»latencia multiplicando viajespresupuesto 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.
Vigilancia continua: Laravel Debugbar para desarrollo local (queries por request a la vista); slow query log del servidor y umbrales de consultas repetidas para producción. El N+1 no avisa cuando llega: se vigila o se padece.

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

ParteCapsLo que te llevas
I · Por qué un ORM1–5Active Record vs Data Mapper; ventajas honestas y costos reales
II · Instalación standalone6–10Composer, Capsule, utf8mb4 y la base determinista
III · CRUD básico11–17Builder completo, modelos, dirty tracking, paginación propia
IV · Relaciones18–23belongsTo/hasMany, N+1 medido y curado, whereHas, dashboard auditado
V · Avanzado24–30Scopes, casts/accessors, colecciones, transacciones, streaming, EXPLAIN
VI · Puente a Laravel31–33Migraciones, 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.

Serie PHP webcode: lenguaje · MVC · Eloquent — completos y encadenados. Siguiente parada natural: el manual de Laravel, donde todo esto cobra velocidad.

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.