Skip to content

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ía

Los tipos de relación más usados:

RelaciónEjemploMétodo en un ladoMétodo en el otro
Uno a unoUsuario ↔ PerfilhasOnebelongsTo
Uno a muchosCategoría → ProductoshasManybelongsTo
Muchos a muchosProductos ↔ EtiquetasbelongsToManybelongsToMany
A través dePaís → Usuarios → PedidoshasManyThrough—
PolimórficaComentarios en posts y videosmorphManymorphTo

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_table
php
<?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_total

El problema N+1 y el eager loading ​

Mira este código aparentemente inocente:

php
// Controlador
$productos = Producto::all();                 // 1 consulta
blade
{{-- Vista --}}
@foreach ($productos as $producto)
    {{-- ¡Cada vuelta hace OTRA consulta para traer la categoría! --}}
    <p>{{ $producto->nombre }} - {{ $producto->categoria->nombre }}</p>
@endforeach

Con 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 ​

  1. Un Cliente tiene muchas Direccion (calle, ciudad, cp). Crea migración y modelos con la relación en ambos sentidos.
  2. Obtén todos los clientes con su número de direcciones y ordénalos de mayor a menor.
  3. Crea la relación muchos a muchos Curso ↔ Alumno con un campo extra calificacion en el pivote. Inscribe al alumno 1 en los cursos 2 y 3 con calificación nula.
  4. Corrige este código con N+1: $posts = Post::all(); y en la vista se muestra $post->autor->name y el número de comentarios con $post->comentarios->count().
Ver solución
  1. 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);
}
  1. Conteo:
php
$clientes = Cliente::withCount('direcciones')
    ->orderByDesc('direcciones_count')
    ->get();
  1. 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]);
  1. 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>
@endforeach

Resumen ​

  • 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() con cascadeOnDelete, nullOnDelete o restrictOnDelete.
  • Muchos a muchos usa tabla pivote; attach, detach, sync y withPivot para datos extra.
  • Consulta por relaciones con has, whereHas, withCount, withSum.
  • Evita N+1 con with() / load() y activa Model::preventLazyLoading() en local.
  • Usa DB::transaction() cuando una operación toca varias tablas.

Academia Web — aprende, copia y construye.