Tema
Relaciones Eloquent
Los datos reales están conectados: un cliente tiene muchos pedidos, un pedido tiene muchos productos, un producto pertenece a una categoría. En esta lección aprenderás a modelar esas conexiones con Eloquent, a consultarlas de forma eficiente y a evitar el famoso problema N+1, uno de los errores de rendimiento más comunes.
Concepto
En una base de datos relacional, las tablas se conectan con llaves foráneas (foreign keys): una columna que guarda el id de una fila de otra tabla. Por ejemplo, productos.categoria_id apunta a categorias.id.
Eloquent convierte esas conexiones en métodos de tus modelos. En lugar de escribir un JOIN, haces:
php
$producto->categoria->nombre; // la categoría de un producto
$categoria->productos; // todos los productos de una categoríaLos tipos de relación más usados:
| Relación | Ejemplo | Método en un lado | Método en el otro |
|---|---|---|---|
| Uno a uno | Usuario ↔ Perfil | hasOne | belongsTo |
| Uno a muchos | Categoría → Productos | hasMany | belongsTo |
| Muchos a muchos | Productos ↔ Etiquetas | belongsToMany | belongsToMany |
| A través de | País → Usuarios → Pedidos | hasManyThrough | — |
| Polimórfica | Comentarios en posts y videos | morphMany | morphTo |
Regla para no confundirte: el modelo cuya tabla tiene la llave foránea "pertenece a" (belongsTo) el otro.
Ejemplo: tienda con categorías, productos, etiquetas y pedidos
1. Migraciones
powershell
php artisan make:model Categoria -m
php artisan make:model Etiqueta -m
php artisan make:model Pedido -m
php artisan make:migration create_etiqueta_producto_table
php artisan make:migration create_pedido_producto_table
php artisan make:migration agregar_categoria_id_a_productos_tablephp
<?php
// create_categorias_table
Schema::create('categorias', function (Blueprint $table) {
$table->id();
$table->string('nombre', 80)->unique();
$table->timestamps();
});php
<?php
// agregar_categoria_id_a_productos_table (tabla productos de la lección anterior)
Schema::table('productos', function (Blueprint $table) {
// foreignId crea BIGINT UNSIGNED; constrained() crea la llave foránea a "categorias"
$table->foreignId('categoria_id')
->nullable()
->after('id')
->constrained('categorias')
->nullOnDelete(); // si se borra la categoría, deja el campo en NULL
});php
<?php
// create_pedidos_table
Schema::create('pedidos', function (Blueprint $table) {
$table->id();
// Un pedido pertenece a un usuario; si se borra el usuario, se borran sus pedidos
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->enum('estado', ['pendiente', 'pagado', 'enviado', 'entregado'])->default('pendiente');
$table->decimal('total', 10, 2)->default(0);
$table->timestamps();
});php
<?php
// create_etiquetas_table
Schema::create('etiquetas', function (Blueprint $table) {
$table->id();
$table->string('nombre', 50)->unique();
$table->timestamps();
});
// create_etiqueta_producto_table: tabla pivote (nombres en singular y orden alfabético)
Schema::create('etiqueta_producto', function (Blueprint $table) {
$table->foreignId('etiqueta_id')->constrained()->cascadeOnDelete();
$table->foreignId('producto_id')->constrained()->cascadeOnDelete();
$table->primary(['etiqueta_id', 'producto_id']); // evita duplicados
});php
<?php
// create_pedido_producto_table: pivote con datos extra
Schema::create('pedido_producto', function (Blueprint $table) {
$table->id();
$table->foreignId('pedido_id')->constrained()->cascadeOnDelete();
$table->foreignId('producto_id')->constrained()->restrictOnDelete(); // no borrar productos vendidos
$table->unsignedInteger('cantidad');
$table->decimal('precio_unitario', 10, 2); // precio al momento de la compra
$table->timestamps();
});Buena práctica
Guarda el precio_unitario en la tabla del pedido. Si mañana cambias el precio del producto, los pedidos antiguos deben conservar el precio con el que se vendieron.
2. Modelos
php
<?php
// app/Models/Categoria.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Categoria extends Model
{
protected $fillable = ['nombre'];
// Una categoría TIENE MUCHOS productos
public function productos(): HasMany
{
return $this->hasMany(Producto::class);
}
}php
<?php
// app/Models/Producto.php (se agregan las relaciones al modelo existente)
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Producto extends Model
{
protected $fillable = ['categoria_id', 'nombre', 'slug', 'descripcion', 'precio', 'stock', 'activo'];
// Un producto PERTENECE A una categoría (la FK categoria_id está en productos)
public function categoria(): BelongsTo
{
return $this->belongsTo(Categoria::class);
}
// Muchos a muchos con etiquetas (tabla pivote etiqueta_producto)
public function etiquetas(): BelongsToMany
{
return $this->belongsToMany(Etiqueta::class);
}
// Muchos a muchos con pedidos, con columnas extra en el pivote
public function pedidos(): BelongsToMany
{
return $this->belongsToMany(Pedido::class)
->withPivot(['cantidad', 'precio_unitario'])
->withTimestamps();
}
}php
<?php
// app/Models/Etiqueta.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Etiqueta extends Model
{
protected $fillable = ['nombre'];
public function productos(): BelongsToMany
{
return $this->belongsToMany(Producto::class);
}
}php
<?php
// app/Models/Pedido.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Pedido extends Model
{
protected $fillable = ['user_id', 'estado', 'total'];
public function usuario(): BelongsTo
{
// El método no se llama "user", así que indicamos la FK explícitamente
return $this->belongsTo(User::class, 'user_id');
}
public function productos(): BelongsToMany
{
return $this->belongsToMany(Producto::class)
->withPivot(['cantidad', 'precio_unitario'])
->withTimestamps();
}
}Y en app/Models/User.php agrega:
php
use Illuminate\Database\Eloquent\Relations\HasMany;
// Un usuario TIENE MUCHOS pedidos
public function pedidos(): HasMany
{
return $this->hasMany(Pedido::class);
}Usar las relaciones
Leer
php
$producto = Producto::find(1);
// Como PROPIEDAD: ejecuta la consulta y devuelve el resultado
echo $producto->categoria->nombre; // "Hogar"
echo $producto->categoria?->nombre; // ?-> por si la categoría es null
foreach ($producto->etiquetas as $etiqueta) {
echo $etiqueta->nombre;
}
// Como MÉTODO: devuelve una consulta que puedes seguir filtrando
$pedidosPagados = $usuario->pedidos()->where('estado', 'pagado')->latest()->get();
$cuantos = $categoria->productos()->count();
// Datos del pivote
foreach ($pedido->productos as $producto) {
echo "{$producto->nombre}: {$producto->pivot->cantidad} x \${$producto->pivot->precio_unitario}";
}Crear y asociar
php
// Crear un producto dentro de una categoría (llena categoria_id solo)
$categoria = Categoria::firstOrCreate(['nombre' => 'Cocina']);
$producto = $categoria->productos()->create([
'nombre' => 'Sartén antiadherente',
'slug' => 'sarten-antiadherente',
'precio' => 399,
]);
// Cambiar la categoría de un producto (belongsTo)
$producto->categoria()->associate($otraCategoria);
$producto->save();
// Muchos a muchos
$producto->etiquetas()->attach([1, 2]); // agrega
$producto->etiquetas()->detach(2); // quita
$producto->etiquetas()->sync([1, 3, 5]); // deja EXACTAMENTE esas (ideal para checkboxes)
$producto->etiquetas()->toggle([4]); // si está la quita, si no la pone
// Pivote con datos extra
$pedido->productos()->attach($producto->id, [
'cantidad' => 2,
'precio_unitario' => $producto->precio,
]);Consultar por relaciones
php
// Categorías que tienen al menos un producto
$conProductos = Categoria::has('productos')->get();
// Productos que tienen la etiqueta "oferta"
$enOferta = Producto::whereHas('etiquetas', function ($q) {
$q->where('nombre', 'oferta');
})->get();
// Usuarios que nunca han comprado
$sinPedidos = User::doesntHave('pedidos')->get();
// Contar sin cargar: agrega la propiedad productos_count
$categorias = Categoria::withCount('productos')->orderByDesc('productos_count')->get();
foreach ($categorias as $c) {
echo "{$c->nombre}: {$c->productos_count} productos";
}
// Sumas
$usuarios = User::withSum('pedidos', 'total')->get(); // $u->pedidos_sum_totalEl problema N+1 y el eager loading
Mira este código aparentemente inocente:
php
// Controlador
$productos = Producto::all(); // 1 consultablade
{{-- Vista --}}
@foreach ($productos as $producto)
{{-- ¡Cada vuelta hace OTRA consulta para traer la categoría! --}}
<p>{{ $producto->nombre }} - {{ $producto->categoria->nombre }}</p>
@endforeachCon 100 productos se ejecutan 101 consultas (1 + N). Con 10 000, tu página tarda segundos. La solución es el eager loading (carga anticipada) con with():
php
// Solo 2 consultas, sin importar cuántos productos haya:
// SELECT * FROM productos
// SELECT * FROM categorias WHERE id IN (1, 2, 3, ...)
$productos = Producto::with('categoria')->get();
// Varias relaciones y relaciones anidadas
$pedidos = Pedido::with(['usuario', 'productos.categoria'])->latest()->paginate(20);
// Eager loading con condiciones y columnas específicas
$categorias = Categoria::with(['productos' => function ($q) {
$q->where('activo', true)->orderBy('nombre');
}])->get();
// Si ya tienes el modelo cargado
$producto->load('etiquetas');Buena práctica: detecta N+1 automáticamente
En app/Providers/AppServiceProvider.php agrega esto para que Laravel lance un error en local cada vez que olvides un with():
php
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
// Solo fuera de producción
Model::preventLazyLoading(! app()->isProduction());
}Caso real: crear un pedido completo
php
<?php
// app/Http/Controllers/PedidoController.php
namespace App\Http\Controllers;
use App\Models\Pedido;
use App\Models\Producto;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class PedidoController extends Controller
{
// POST /pedidos con datos: items[0][producto_id]=1, items[0][cantidad]=2 ...
public function store(Request $request)
{
$datos = $request->validate([
'items' => ['required', 'array', 'min:1'],
'items.*.producto_id' => ['required', 'exists:productos,id'],
'items.*.cantidad' => ['required', 'integer', 'min:1'],
]);
// Transacción: o se guarda TODO o NADA (si algo falla, se revierte)
$pedido = DB::transaction(function () use ($datos, $request) {
$pedido = $request->user()->pedidos()->create(['estado' => 'pendiente']);
$total = 0;
foreach ($datos['items'] as $item) {
// lockForUpdate evita que dos compras simultáneas vendan el mismo stock
$producto = Producto::lockForUpdate()->findOrFail($item['producto_id']);
abort_if($producto->stock < $item['cantidad'], 422, "Sin stock de {$producto->nombre}");
$pedido->productos()->attach($producto->id, [
'cantidad' => $item['cantidad'],
'precio_unitario' => $producto->precio,
]);
$producto->decrement('stock', $item['cantidad']);
$total += $producto->precio * $item['cantidad'];
}
$pedido->update(['total' => $total]);
return $pedido;
});
return redirect()->route('pedidos.show', $pedido)->with('exito', '¡Pedido creado!');
}
// GET /pedidos/{pedido}
public function show(Pedido $pedido)
{
$pedido->load('productos.categoria', 'usuario'); // eager loading
return view('pedidos.show', compact('pedido'));
}
}blade
{{-- resources/views/pedidos/show.blade.php --}}
<x-layout titulo="Pedido #{{ $pedido->id }}">
<h1>Pedido #{{ $pedido->id }} de {{ $pedido->usuario->name }}</h1>
<p>Estado: {{ ucfirst($pedido->estado) }}</p>
<table>
<tr><th>Producto</th><th>Categoría</th><th>Cant.</th><th>Subtotal</th></tr>
@foreach ($pedido->productos as $producto)
<tr>
<td>{{ $producto->nombre }}</td>
<td>{{ $producto->categoria?->nombre ?? 'Sin categoría' }}</td>
<td>{{ $producto->pivot->cantidad }}</td>
<td>${{ number_format($producto->pivot->cantidad * $producto->pivot->precio_unitario, 2) }}</td>
</tr>
@endforeach
</table>
<p><strong>Total: ${{ number_format($pedido->total, 2) }}</strong></p>
</x-layout>Error común
Confundir propiedad y método: $usuario->pedidos devuelve una colección (ya ejecutada); $usuario->pedidos() devuelve una consulta. Para filtrar en la BD usa el método: $usuario->pedidos()->where(...)->get(). Escribir $usuario->pedidos->where(...) funciona, pero filtra en memoria después de traer todos.
Error común
"Integrity constraint violation: Cannot add or update a child row". Intentas guardar un categoria_id que no existe en categorias, o borrar una fila referenciada con restrictOnDelete. Valida con la regla exists:categorias,id y decide bien qué hacer al borrar: cascadeOnDelete, nullOnDelete o restrictOnDelete.
Ejercicios
- Un
Clientetiene muchasDireccion(calle, ciudad, cp). Crea migración y modelos con la relación en ambos sentidos. - Obtén todos los clientes con su número de direcciones y ordénalos de mayor a menor.
- Crea la relación muchos a muchos
Curso↔Alumnocon un campo extracalificacionen el pivote. Inscribe al alumno 1 en los cursos 2 y 3 con calificación nula. - Corrige este código con N+1:
$posts = Post::all();y en la vista se muestra$post->autor->namey el número de comentarios con$post->comentarios->count().
Ver solución
- Clientes y direcciones:
php
// Migración create_direcciones_table
Schema::create('direcciones', function (Blueprint $table) {
$table->id();
$table->foreignId('cliente_id')->constrained()->cascadeOnDelete();
$table->string('calle');
$table->string('ciudad', 80);
$table->string('cp', 5);
$table->timestamps();
});php
// app/Models/Direccion.php
class Direccion extends Model
{
protected $table = 'direcciones'; // el plural automático sería "direccions"
protected $fillable = ['calle', 'ciudad', 'cp'];
public function cliente(): BelongsTo
{
return $this->belongsTo(Cliente::class);
}
}
// app/Models/Cliente.php
public function direcciones(): HasMany
{
return $this->hasMany(Direccion::class);
}- Conteo:
php
$clientes = Cliente::withCount('direcciones')
->orderByDesc('direcciones_count')
->get();- Cursos y alumnos:
php
// Migración create_alumno_curso_table (orden alfabético: alumno, curso)
Schema::create('alumno_curso', function (Blueprint $table) {
$table->id();
$table->foreignId('alumno_id')->constrained()->cascadeOnDelete();
$table->foreignId('curso_id')->constrained()->cascadeOnDelete();
$table->decimal('calificacion', 4, 2)->nullable();
$table->timestamps();
$table->unique(['alumno_id', 'curso_id']);
});php
// app/Models/Alumno.php
public function cursos(): BelongsToMany
{
return $this->belongsToMany(Curso::class)
->withPivot('calificacion')
->withTimestamps();
}
// app/Models/Curso.php
public function alumnos(): BelongsToMany
{
return $this->belongsToMany(Alumno::class)
->withPivot('calificacion')
->withTimestamps();
}php
Alumno::find(1)->cursos()->attach([2, 3]);
// Después, para calificar:
Alumno::find(1)->cursos()->updateExistingPivot(2, ['calificacion' => 9.5]);- Sin N+1:
php
// 2 consultas en vez de 1 + 2N
$posts = Post::with('autor')->withCount('comentarios')->get();blade
@foreach ($posts as $post)
<p>{{ $post->titulo }} — {{ $post->autor->name }} ({{ $post->comentarios_count }} comentarios)</p>
@endforeachResumen
- Las relaciones se definen como métodos en los modelos:
hasOne,hasMany,belongsTo,belongsToMany. - El modelo cuya tabla tiene la llave foránea usa
belongsTo. - En migraciones:
foreignId('x_id')->constrained()concascadeOnDelete,nullOnDeleteorestrictOnDelete. - Muchos a muchos usa tabla pivote;
attach,detach,syncywithPivotpara datos extra. - Consulta por relaciones con
has,whereHas,withCount,withSum. - Evita N+1 con
with()/load()y activaModel::preventLazyLoading()en local. - Usa
DB::transaction()cuando una operación toca varias tablas.