Lección 21 de 45 12 min de lectura

Relaciones Muchos a Muchos

Las relaciones muchos a muchos conectan múltiples registros de una tabla con múltiples registros de otra. En esta lección aprenderás a usar belongsToMany, tablas pivot y los métodos attach, detach y sync.

Concepto de relación muchos a muchos

Una relación muchos a muchos (N:M) ocurre cuando un registro puede estar asociado con múltiples registros de otra tabla, y viceversa. Por ejemplo:

  • Un post puede tener muchas etiquetas
  • Una etiqueta puede pertenecer a muchos posts

Para implementar esta relación necesitas una tabla intermedia (pivot table) que almacena las conexiones entre ambas tablas.

La tabla pivot

La tabla pivot contiene las claves foráneas de ambas tablas relacionadas. Por convención, su nombre combina los nombres de ambas tablas en singular, ordenados alfabéticamente.

php
<?php

// Migración de tags (etiquetas)
Schema::create('tags', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('slug')->unique();
    $table->timestamps();
});

// Migración de la tabla pivot: post_tag
// Nombre: post + tag (orden alfabético)
Schema::create('post_tag', function (Blueprint $table) {
    $table->id();
    $table->foreignId('post_id')->constrained()->onDelete('cascade');
    $table->foreignId('tag_id')->constrained()->onDelete('cascade');
    $table->timestamps();

    // Evitar duplicados
    $table->unique(['post_id', 'tag_id']);
});
Convención de nombres

Laravel espera que la tabla pivot se llame con los nombres de ambos modelos en singular y orden alfabético: post_tag, no tag_post. Si usas otro nombre, deberás especificarlo en la relación.

Definir la relación con belongsToMany

Usa belongsToMany en ambos modelos. A diferencia de las relaciones uno a uno/muchos, aquí ambos lados usan el mismo método.

php
<?php

// app/Models/Post.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class Post extends Model
{
    protected $fillable = ['user_id', 'title', 'slug', 'content'];

    public function tags(): BelongsToMany
    {
        return $this->belongsToMany(Tag::class);
    }
}
php
<?php

// app/Models/Tag.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class Tag extends Model
{
    protected $fillable = ['name', 'slug'];

    public function posts(): BelongsToMany
    {
        return $this->belongsToMany(Post::class);
    }
}

Acceder a los datos relacionados

php
<?php

use App\Models\Post;
use App\Models\Tag;

// Obtener las etiquetas de un post
$post = Post::find(1);

foreach ($post->tags as $tag) {
    echo $tag->name;
}

// Obtener los posts de una etiqueta
$tag = Tag::where('slug', 'laravel')->first();

foreach ($tag->posts as $post) {
    echo $post->title;
}

Gestionar la relación: attach, detach, sync

Laravel proporciona métodos para añadir, eliminar y sincronizar registros en la tabla pivot.

attach() - Añadir relaciones

php
<?php

use App\Models\Post;

$post = Post::find(1);

// Añadir una etiqueta (por ID)
$post->tags()->attach(1);

// Añadir múltiples etiquetas
$post->tags()->attach([1, 2, 3]);

// Añadir con datos extra en la tabla pivot
$post->tags()->attach(1, ['added_by' => auth()->id()]);

detach() - Eliminar relaciones

php
<?php

$post = Post::find(1);

// Eliminar una etiqueta específica
$post->tags()->detach(1);

// Eliminar múltiples etiquetas
$post->tags()->detach([1, 2]);

// Eliminar TODAS las etiquetas
$post->tags()->detach();

sync() - Sincronizar relaciones

sync() es el método más útil: reemplaza todas las relaciones existentes con las que le pases.

php
<?php

$post = Post::find(1);

// El post tendrá SOLO estas etiquetas (elimina las demás)
$post->tags()->sync([1, 3, 5]);

// syncWithoutDetaching: añade sin eliminar las existentes
$post->tags()->syncWithoutDetaching([2, 4]);
sync() vs attach()

Usa sync() cuando el usuario envía una lista completa de relaciones (ej: checkboxes en un formulario). Usa attach() cuando añades relaciones incrementalmente sin afectar las existentes.

Datos adicionales en la tabla pivot

Puedes almacenar información extra en la tabla pivot, como la fecha en que se añadió una etiqueta o quién la añadió.

php
<?php

// Migración con columnas extra
Schema::create('post_tag', function (Blueprint $table) {
    $table->id();
    $table->foreignId('post_id')->constrained()->onDelete('cascade');
    $table->foreignId('tag_id')->constrained()->onDelete('cascade');
    $table->foreignId('added_by')->nullable()->constrained('users');
    $table->timestamps();

    $table->unique(['post_id', 'tag_id']);
});

Para acceder a estos datos, usa withPivot() y withTimestamps() en la relación:

php
<?php

// app/Models/Post.php
public function tags(): BelongsToMany
{
    return $this->belongsToMany(Tag::class)
        ->withPivot('added_by')
        ->withTimestamps();
}

// Uso
$post = Post::find(1);

foreach ($post->tags as $tag) {
    echo $tag->name;
    echo $tag->pivot->added_by;      // ID del usuario
    echo $tag->pivot->created_at;    // Cuándo se añadió
}

Ejemplo práctico: Roles de usuario

Un caso común es asignar roles a usuarios, donde un usuario puede tener múltiples roles y un rol puede pertenecer a múltiples usuarios.

php
<?php

// Migraciones
Schema::create('roles', function (Blueprint $table) {
    $table->id();
    $table->string('name');       // admin, editor, author
    $table->string('slug')->unique();
    $table->timestamps();
});

Schema::create('role_user', function (Blueprint $table) {
    $table->id();
    $table->foreignId('role_id')->constrained()->onDelete('cascade');
    $table->foreignId('user_id')->constrained()->onDelete('cascade');
    $table->timestamps();

    $table->unique(['role_id', 'user_id']);
});
php
<?php

// app/Models/Role.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class Role extends Model
{
    protected $fillable = ['name', 'slug'];

    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class)->withTimestamps();
    }
}

// En User.php, añadir:
public function roles(): BelongsToMany
{
    return $this->belongsToMany(Role::class)->withTimestamps();
}

// Método helper para verificar rol
public function hasRole(string $slug): bool
{
    return $this->roles()->where('slug', $slug)->exists();
}
php
<?php

use App\Models\User;
use App\Models\Role;

// Asignar roles a un usuario
$user = User::find(1);
$adminRole = Role::where('slug', 'admin')->first();
$editorRole = Role::where('slug', 'editor')->first();

$user->roles()->attach([$adminRole->id, $editorRole->id]);

// Verificar si tiene un rol
if ($user->hasRole('admin')) {
    // Es administrador
}

// Obtener usuarios con un rol específico
$admins = Role::where('slug', 'admin')
    ->first()
    ->users;

Eager Loading en relaciones N:M

Al igual que con otras relaciones, usa with() para evitar el problema N+1:

php
<?php

// Cargar posts con sus etiquetas
$posts = Post::with('tags')->get();

foreach ($posts as $post) {
    foreach ($post->tags as $tag) {
        echo $tag->name;  // Sin consulta adicional
    }
}

// Cargar usuarios con sus roles
$users = User::with('roles')->get();

Resumen

  • Las relaciones muchos a muchos requieren una tabla pivot
  • El nombre de la tabla pivot es alfabético: post_tag, role_user
  • Ambos modelos usan belongsToMany
  • attach() añade relaciones, detach() las elimina
  • sync() reemplaza todas las relaciones con las especificadas
  • Usa withPivot() para acceder a columnas extra de la tabla pivot
  • Recuerda usar with() para eager loading

Ejercicios

Ejercicio 1: Sistema de etiquetas

Crea las migraciones y modelos para un sistema de etiquetas en posts. Implementa belongsToMany en ambos modelos con timestamps.

Ver solución
<?php

// database/migrations/xxxx_create_tags_table.php
Schema::create('tags', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('slug')->unique();
    $table->timestamps();
});

// database/migrations/xxxx_create_post_tag_table.php
Schema::create('post_tag', function (Blueprint $table) {
    $table->id();
    $table->foreignId('post_id')->constrained()->onDelete('cascade');
    $table->foreignId('tag_id')->constrained()->onDelete('cascade');
    $table->timestamps();

    $table->unique(['post_id', 'tag_id']);
});

// app/Models/Tag.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class Tag extends Model
{
    protected $fillable = ['name', 'slug'];

    public function posts(): BelongsToMany
    {
        return $this->belongsToMany(Post::class)->withTimestamps();
    }
}

// En Post.php, añadir:
public function tags(): BelongsToMany
{
    return $this->belongsToMany(Tag::class)->withTimestamps();
}

Ejercicio 2: Gestionar etiquetas de un post

Escribe el código para: crear 3 etiquetas, asignarlas a un post usando sync(), y luego mostrar las etiquetas del post.

Ver solución
<?php

use App\Models\Post;
use App\Models\Tag;

// Crear etiquetas
$laravel = Tag::create(['name' => 'Laravel', 'slug' => 'laravel']);
$php = Tag::create(['name' => 'PHP', 'slug' => 'php']);
$tutorial = Tag::create(['name' => 'Tutorial', 'slug' => 'tutorial']);

// Obtener un post
$post = Post::find(1);

// Asignar etiquetas con sync
$post->tags()->sync([
    $laravel->id,
    $php->id,
    $tutorial->id,
]);

// Mostrar etiquetas del post
foreach ($post->tags as $tag) {
    echo $tag->name . ' ';  // Laravel PHP Tutorial
}

// También puedes cargar con eager loading
$post = Post::with('tags')->find(1);

echo 'Etiquetas: ' . $post->tags->pluck('name')->join(', ');
// Etiquetas: Laravel, PHP, Tutorial

Ejercicio 3: Posts por etiqueta

Escribe una consulta que obtenga todos los posts que tienen la etiqueta "laravel", ordenados por fecha de creación, con eager loading del autor.

Ver solución
<?php

use App\Models\Post;
use App\Models\Tag;

// Opción 1: Desde el modelo Tag
$tag = Tag::where('slug', 'laravel')->first();

$posts = $tag->posts()
    ->with('user')
    ->orderBy('created_at', 'desc')
    ->get();

// Opción 2: Desde el modelo Post con whereHas
$posts = Post::with('user')
    ->whereHas('tags', function ($query) {
        $query->where('slug', 'laravel');
    })
    ->orderBy('created_at', 'desc')
    ->get();

// Uso
foreach ($posts as $post) {
    echo $post->title . ' por ' . $post->user->name;
}

¿Te está gustando el curso?

Tenemos cursos premium con proyectos reales, soporte personalizado y certificado.

Descubrir cursos premium