Oscar Coleto

Relaciones Polimórficas en Laravel: Guía Completa con Casos Reales

Introducción

Las relaciones polimórficas son uno de los conceptos más potentes —y más malentendidos— de Eloquent. Permiten que un modelo se relacione con múltiples modelos usando una sola asociación, eliminando tablas duplicadas y centralizando lógica repetida.

Sin embargo, usadas sin criterio, pueden convertirse en una fuente de complejidad oculta, consultas lentas y código difícil de mantener.

En este artículo aprenderás:

Qué son las relaciones polimórficas y cómo funcionan internamente

Los tres tipos: morphOne, morphMany y morphToMany

Cuándo usarlas y cuándo evitarlas

Optimización de consultas con eager loading

Patrones avanzados con ejemplos reales de producción

¿Qué Es una Relación Polimórfica?

Imagina que tienes una aplicación donde tanto Post como Video pueden tener Comment. La solución obvia sería crear dos tablas: post_comments y video_comments. Pero si mañana añades Podcast, necesitas una tercera tabla. Y si añades Tweet… ya ves el problema.

Las relaciones polimórficas resuelven esto con una sola tabla y dos columnas especiales:

  • commentable_id — el ID del modelo relacionado
  • commentable_type — el nombre de la clase del modelo relacionado
comments
├── id
├── body
├── commentable_id    ← 1, 42, 7...
├── commentable_type  ← "App\Models\Post", "App\Models\Video"...
└── created_at

Laravel usa la convención {nombre}able para estas columnas por defecto.

Tipo 1: Uno a Uno Polimórfico (morphOne)

Un modelo tiene un solo registro relacionado, pero ese registro puede pertenecer a distintos modelos.

Caso real: Cada User y cada Team pueden tener exactamente una Image de perfil.

Migración

Schema::create('images', function (Blueprint $table) {
    $table->id();
    $table->string('url');
    $table->string('alt')->nullable();
    $table->morphs('imageable'); // crea imageable_id e imageable_type
    $table->timestamps();
});

morphs() es un atajo que crea ambas columnas e indexa la combinación automáticamente.

Modelos

// app/Models/Image.php
class Image extends Model
{
    public function imageable(): MorphTo
    {
        return $this->morphTo();
    }
}

// app/Models/User.php
class User extends Model
{
    public function image(): MorphOne
    {
        return $this->morphOne(Image::class, 'imageable');
    }
}

// app/Models/Team.php
class Team extends Model
{
    public function image(): MorphOne
    {
        return $this->morphOne(Image::class, 'imageable');
    }
}

Uso

// Crear imagen para un usuario
$user->image()->create([
    'url' => 'https://cdn.example.com/avatars/john.jpg',
    'alt' => 'Avatar de John',
]);

// Crear imagen para un equipo
$team->image()->create([
    'url' => 'https://cdn.example.com/teams/dev.png',
    'alt' => 'Logo del equipo Dev',
]);

// Obtener el propietario desde la imagen (dirección inversa)
$image = Image::find(1);
$owner = $image->imageable; // devuelve un User o un Team

Tipo 2: Uno a Muchos Polimórfico (morphMany)

Es el caso más común. Un modelo puede tener múltiples registros relacionados, y esos registros pueden pertenecer a distintos modelos.

Caso real: Post, Video y Product pueden recibir Comment.

Migración

Schema::create('comments', function (Blueprint $table) {
    $table->id();
    $table->text('body');
    $table->foreignId('user_id')->constrained();
    $table->morphs('commentable');
    $table->timestamps();
});

Modelos

// app/Models/Comment.php
class Comment extends Model
{
    protected $fillable = ['body', 'user_id'];

    public function commentable(): MorphTo
    {
        return $this->morphTo();
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

// app/Models/Post.php
class Post extends Model
{
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

// app/Models/Video.php
class Video extends Model
{
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

Uso

// Añadir un comentario a un post
$post->comments()->create([
    'body' => 'Excelente artículo.',
    'user_id' => auth()->id(),
]);

// Listar comentarios de un video
$comments = $video->comments()->latest()->paginate(10);

// Dirección inversa: saber a qué pertenece un comentario
$comment = Comment::find(1);
echo get_class($comment->commentable); // App\Models\Post

Tipo 3: Muchos a Muchos Polimórfico (morphToMany)

El más complejo. Permite relaciones muchos a muchos donde la tabla pivot acepta distintos modelos.

Caso real: Post, Video y Product pueden tener Tag, y cada Tag puede estar en múltiples modelos de distintos tipos.

Migración

Schema::create('tags', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('slug')->unique();
    $table->timestamps();
});

Schema::create('taggables', function (Blueprint $table) {
    $table->foreignId('tag_id')->constrained()->cascadeOnDelete();
    $table->morphs('taggable'); // taggable_id + taggable_type
});

Modelos

// app/Models/Tag.php
class Tag extends Model
{
    public function posts(): MorphedByMany
    {
        return $this->morphedByMany(Post::class, 'taggable');
    }

    public function videos(): MorphedByMany
    {
        return $this->morphedByMany(Video::class, 'taggable');
    }
}

// app/Models/Post.php
class Post extends Model
{
    public function tags(): MorphToMany
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }
}

// app/Models/Video.php
class Video extends Model
{
    public function tags(): MorphToMany
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }
}

Uso

// Sincronizar tags en un post
$post->tags()->sync([1, 3, 5]);

// Adjuntar un tag a un video
$video->tags()->attach($tag);

// Obtener todos los posts con un tag específico
$tag = Tag::where('slug', 'laravel')->first();
$posts = $tag->posts()->published()->paginate(15);

El Problema del N+1 en Relaciones Polimórficas

Aquí está la trampa más común. Con relaciones normales, with() resuelve el N+1. Con polimórficas, es más delicado.

El problema

// Esto genera N+1 queries
$comments = Comment::all();

foreach ($comments as $comment) {
    echo $comment->commentable->title; // query por cada comentario
}

Si tienes 100 comentarios de 3 tipos distintos, esto genera 100 queries adicionales.

La solución: morphWith + eager loading

// Bien: eager loading básico
$comments = Comment::with('commentable')->get();

// Pero si commentable tiene sus propias relaciones...
// Esto sigue siendo ineficiente porque carga toda la relación sin discriminar el tipo

// Mejor: usar MorphMap + eager loading selectivo
$comments = Comment::with([
    'commentable' => function (MorphTo $morphTo) {
        $morphTo->morphWith([
            Post::class => ['author', 'category'],
            Video::class => ['channel'],
        ]);
    }
])->get();

morphWith carga relaciones anidadas por tipo de modelo, evitando queries innecesarias.

MorphMap: Alias de Tipos para la Base de Datos

Por defecto, Laravel almacena el nombre completo de la clase en commentable_type:

App\Models\Post
App\Models\Video

Esto tiene dos problemas: ocupa más espacio y rompe todo si mueves o renombras una clase.

Solución: registrar un MorphMap

// app/Providers/AppServiceProvider.php

use Illuminate\Database\Eloquent\Relations\Relation;

public function boot(): void
{
    Relation::morphMap([
        'post'    => \App\Models\Post::class,
        'video'   => \App\Models\Video::class,
        'product' => \App\Models\Product::class,
    ]);
}

Ahora la base de datos almacena post en lugar de App\Models\Post. Puedes renombrar o mover clases sin tocar la base de datos.

Importante: Si ya tienes datos con el nombre completo de la clase y aplicas MorphMap, deberás migrar esos registros:

// Migración de datos existentes
Comment::where('commentable_type', 'App\Models\Post')
    ->update(['commentable_type' => 'post']);

Caso Real: Sistema de Notificaciones Polimórfico

Uno de los usos más potentes es un sistema de notificaciones genérico donde distintas acciones generan notificaciones con contexto diferente.

Estructura

Schema::create('notifications', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->foreignId('user_id')->constrained();
    $table->morphs('notifiable');  // el objeto que originó la notificación
    $table->string('type');
    $table->json('data')->nullable();
    $table->timestamp('read_at')->nullable();
    $table->timestamps();
});

Modelos

// app/Models/Notification.php
class Notification extends Model
{
    public $incrementing = false;
    protected $keyType = 'string';

    protected $casts = [
        'data' => 'array',
        'read_at' => 'datetime',
    ];

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function notifiable(): MorphTo
    {
        return $this->morphTo();
    }

    public function markAsRead(): void
    {
        $this->update(['read_at' => now()]);
    }

    public function scopeUnread(Builder $query): Builder
    {
        return $query->whereNull('read_at');
    }
}

Uso con distintos tipos de eventos

// Cuando alguien comenta en un post
$user->notifications()->create([
    'id'              => Str::uuid(),
    'type'            => 'comment',
    'notifiable_id'   => $post->id,
    'notifiable_type' => 'post',
    'data'            => [
        'commenter' => $commenter->name,
        'excerpt'   => Str::limit($comment->body, 80),
    ],
]);

// Cuando alguien hace un pedido
$admin->notifications()->create([
    'id'              => Str::uuid(),
    'type'            => 'new_order',
    'notifiable_id'   => $order->id,
    'notifiable_type' => 'order',
    'data'            => [
        'total'    => $order->total,
        'customer' => $order->customer->name,
    ],
]);

// Consultar con contexto completo
$notifications = $user->notifications()
    ->with([
        'notifiable' => fn (MorphTo $q) => $q->morphWith([
            Post::class  => ['author'],
            Order::class => ['customer'],
        ])
    ])
    ->unread()
    ->latest()
    ->get();

Caso Real: Sistema de Reacciones (Likes)

Un sistema donde usuarios pueden reaccionar con distintos emojis a Post, Comment y Video.

Schema::create('reactions', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->morphs('reactable');
    $table->string('type'); // 'like', 'love', 'haha', 'wow'
    $table->unique(['user_id', 'reactable_id', 'reactable_type', 'type']);
    $table->timestamps();
});
// Trait reutilizable para cualquier modelo reactable
// app/Traits/HasReactions.php
trait HasReactions
{
    public function reactions(): MorphMany
    {
        return $this->morphMany(Reaction::class, 'reactable');
    }

    public function react(string $type, User $user): Reaction
    {
        return $this->reactions()->updateOrCreate(
            ['user_id' => $user->id, 'type' => $type],
            []
        );
    }

    public function unreact(string $type, User $user): void
    {
        $this->reactions()
            ->where('user_id', $user->id)
            ->where('type', $type)
            ->delete();
    }

    public function reactionCount(string $type): int
    {
        return $this->reactions()->where('type', $type)->count();
    }
}

// Aplicar el trait en los modelos
class Post extends Model
{
    use HasReactions;
}

class Comment extends Model
{
    use HasReactions;
}
// Uso limpio y consistente
$post->react('like', auth()->user());
$comment->react('love', auth()->user());

echo $post->reactionCount('like'); // 42

Cuándo NO Usar Relaciones Polimórficas

Las relaciones polimórficas no son siempre la respuesta correcta. Evítalas cuando:

Solo tienes dos modelos posibles. Una relación normal con foreign keys es más simple y más eficiente.

Necesitas integridad referencial. Las foreign keys polimórficas no pueden tener FOREIGN KEY constraints en MySQL/PostgreSQL porque apuntan a tablas dinámicas. Si necesitas garantías de base de datos, usa tablas separadas.

El tipo cambia con frecuencia. Cambiar el _type es costoso y propenso a errores.

Necesitas reportes complejos. Las queries analíticas sobre columnas polimórficas son difíciles de optimizar.

Alternativa: Tabla por Tipo

Si solo tienes 2-3 modelos relacionados y necesitas integridad referencial:

Schema::create('comments', function (Blueprint $table) {
    $table->id();
    $table->text('body');
    $table->foreignId('post_id')->nullable()->constrained()->nullOnDelete();
    $table->foreignId('video_id')->nullable()->constrained()->nullOnDelete();
    // Solo uno de estos puede ser no-null por restricción de aplicación
    $table->timestamps();
});

Más verboso, pero con constraints reales en la base de datos.

Testing de Relaciones Polimórficas

// tests/Unit/Models/CommentTest.php

use App\Models\{Comment, Post, Video, User};

it('belongs to a post via polymorphic relation', function () {
    $post = Post::factory()->create();
    $comment = Comment::factory()->for($post, 'commentable')->create();

    expect($comment->commentable)->toBeInstanceOf(Post::class);
    expect($comment->commentable->id)->toBe($post->id);
});

it('belongs to a video via polymorphic relation', function () {
    $video = Video::factory()->create();
    $comment = Comment::factory()->for($video, 'commentable')->create();

    expect($comment->commentable)->toBeInstanceOf(Video::class);
});

it('loads commentable efficiently without N+1', function () {
    Post::factory()->count(5)->has(
        Comment::factory()->count(3), 'comments'
    )->create();

    $queryCount = 0;
    DB::listen(fn () => $queryCount++);

    Comment::with('commentable')->get()->each(fn ($c) => $c->commentable->id);

    // Solo 2 queries: una para comments, una para posts
    expect($queryCount)->toBeLessThanOrEqual(2);
});

Factory con soporte polimórfico

// database/factories/CommentFactory.php
class CommentFactory extends Factory
{
    public function definition(): array
    {
        return [
            'body'    => $this->faker->paragraph(),
            'user_id' => User::factory(),
        ];
    }

    public function forPost(): static
    {
        return $this->state(fn () => [
            'commentable_type' => 'post',
            'commentable_id'   => Post::factory(),
        ]);
    }

    public function forVideo(): static
    {
        return $this->state(fn () => [
            'commentable_type' => 'video',
            'commentable_id'   => Video::factory(),
        ]);
    }
}

Resumen: Cuándo Usar Cada Tipo

TipoMétodoCuándo usarlo
Uno a UnomorphOne / morphToImagen de perfil, dirección, configuración única
Uno a MuchosmorphMany / morphToComentarios, logs, archivos adjuntos, reacciones
Muchos a MuchosmorphToMany / morphedByManyTags, categorías, permisos compartidos

Conclusión

Las relaciones polimórficas son una herramienta poderosa cuando el caso de uso lo justifica. La clave está en:

  1. Usar MorphMap siempre — protege contra refactorizaciones y ahorra espacio en disco.
  2. Eager loading con morphWith — evita el N+1 cuando las relaciones anidadas varían por tipo.
  3. Traits para lógica compartida — centraliza el comportamiento en lugar de duplicar métodos.
  4. Evaluar la alternativa — si solo son 2 modelos o necesitas constraints reales, las foreign keys normales son mejores.

Aplicadas correctamente, eliminan tablas duplicadas, centralizan lógica y hacen que añadir nuevos tipos sea trivial: crea el modelo, añade el método y ya está.

C O M E N T A R I O S

Deja un comentario

0/2000 caracteres

Tu email no será publicado. Los campos marcados con * son obligatorios.

Cargando comentarios...

☕ ¿Te ha sido útil este artículo?

Apóyame con un café mientras sigo creando contenido técnico

☕ Invítame un café