Volver al blog

Relaciones polimórficas en Laravel 13: una tabla para muchos dueños

28 ago 2026 17 min de lecturaLaravel

Qué es una tabla polimórfica, por qué resuelve el problema de compartir comentarios, imágenes o etiquetas entre varios modelos, y cómo se implementa en Laravel 13 con morphOne, morphMany y morphToMany —migraciones, morph map, carga sin N+1 y cuándo conviene evitarlas.

Relaciones polimórficas en Laravel 13: una tabla para muchos dueños

Tarde o temprano te topas con el mismo problema: los comentarios sirven para las publicaciones y para los videos. Las etiquetas valen para los artículos y para los productos. Una imagen puede pertenecer a un usuario o a una publicación. La reacción instintiva es duplicar tablas —post_comments, video_comments— y con ellas duplicar modelos, validaciones y consultas. Las relaciones polimórficas son la respuesta de Eloquent a ese picor: una sola tabla que puede pertenecer a varios modelos distintos, sin repetir esquema. Vamos a ver qué son a nivel de base de datos, cómo las modela Laravel 13 y —tan importante como lo anterior— cuándo es mejor no usarlas.

Qué es una tabla polimórfica

Olvidémonos de Laravel por un momento y quedémonos en la base de datos. Una relación normal usa una clave foránea: la columna post_id de la tabla comments apunta siempre a la tabla posts. El destino es fijo, y el motor lo garantiza con una restricción FOREIGN KEY.

Una relación polimórfica cambia esa regla: el destino ya no es una sola tabla, sino que se decide fila por fila. Para lograrlo no basta con guardar un id; hace falta guardar también a qué tabla pertenece ese id. Por eso una tabla polimórfica lleva dos columnas en lugar de una:

  • Una columna de id (commentable_id) — a qué fila apunta.
  • Una columna de tipo (commentable_type) — a qué tabla, o mejor dicho, a qué modelo, pertenece esa fila.

Ese par (tipo, id) es lo que hace la magia. Una misma fila de comments puede decir «pertenezco a la publicación 42» y la de al lado «pertenezco al video 7». La tabla es poli-mórfica justamente porque adopta muchas formas de dueño.

Diagrama animado: una única tabla comments con las columnas commentable_id y commentable_type; según el valor de commentable_type ('post' o 'video'), Eloquent resuelve la fila hacia la tabla posts o hacia la tabla videos, mostrando que una sola fila puede tener padres de distinto tipo

El detalle que casi nadie menciona al principio

Ese par (tipo, id) no es una clave foránea real. El motor de base de datos no sabe que commentable_id = 42 debe existir en posts: para él son dos columnas sueltas. Quien garantiza la integridad es tu aplicación —Eloquent—, no PostgreSQL ni MySQL. Es el intercambio de fondo de todo este patrón: ganas flexibilidad y pierdes la red de seguridad que te daba el FOREIGN KEY. Guárdalo en la cabeza; volveremos a ello al final.

Cómo lo modela Laravel 13

Laravel expone el patrón con tres sabores, según la cardinalidad de la relación. La sintaxis apenas cambia entre versiones; lo que sí trae Laravel 13 es PHP 8.3 como mínimo, así que aprovechamos los tipos de retorno en cada método de relación (: MorphTo, : MorphMany), que documentan la intención y ayudan al análisis estático.

morphOne / morphTo — una imagen que pertenece a un usuario o a una publicación.

php
// App\Models\Image  (el lado hijo)
use Illuminate\Database\Eloquent\Relations\MorphTo;

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

// App\Models\Post y App\Models\User  (los dueños)
use Illuminate\Database\Eloquent\Relations\MorphOne;

class Post extends Model
{
    public function image(): MorphOne
    {
        return $this->morphOne(Image::class, 'imageable');
    }
}
php
$post  = Post::find(1);
$image = $post->image;          // la imagen de la publicación

$image     = Image::find(1);
$imageable = $image->imageable; // devuelve un Post o un User, según la fila

La convención que ata todo

El segundo argumento ('commentable', 'imageable', 'taggable') es el nombre de la relación, y de él salen los nombres de columna: commentable_id + commentable_type. Mantén ese nombre coherente entre la migración, el método hijo (commentable()) y las llamadas morphMany/morphToMany, y Eloquent une las piezas por convención, sin que tengas que configurar nada.

La migración: dos columnas y un índice

Aquí es donde el patrón se vuelve tangible. Laravel trae ayudantes de migración que crean el par de columnas y —esto importa— el índice compuesto de una sola línea.

Declara las columnas polimórficas

$table->morphs('commentable') crea commentable_id (un BIGINT UNSIGNED) y commentable_type (un VARCHAR), y además un índice sobre ambas.

php
Schema::create('comments', function (Blueprint $table) {
    $table->id();
    $table->text('body');
    $table->morphs('commentable'); // _id + _type + índice compuesto
    $table->timestamps();
});

Elige la variante según tus llaves

Si el dueño puede no existir todavía, usa nullableMorphs(). Si tu proyecto usa UUID o ULID como clave primaria, están uuidMorphs() y ulidMorphs(), que ajustan el tipo de la columna _id.

php
$table->nullableMorphs('commentable'); // permite NULL
$table->uuidMorphs('commentable');     // _id como UUID
$table->ulidMorphs('commentable');     // _id como ULID

Para muchos a muchos, crea el pivote

La relación morphToMany necesita una tabla intermedia (taggables) con la clave del tag más el par polimórfico:

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

El índice no es opcional

El índice compuesto (commentable_type, commentable_id) que crea morphs() es la diferencia entre una consulta que vuela y una que escanea la tabla entera. Cada vez que pides $post->comments, Eloquent filtra por ambas columnas a la vez; sin ese índice, cada lectura degrada a medida que la tabla crece. Si alguna vez añades las columnas a mano, no olvides el índice.

El morph map: no encadenes tu base de datos a tu namespace

Por defecto, commentable_type guarda el nombre completo de la clase: la cadena literal App\Models\Post. Funciona, pero acopla tus datos a la estructura de tu código: el día que muevas Post a otro namespace o renombres el modelo, todas las filas históricas quedan apuntando a una clase que ya no existe.

La solución es el morph map: un diccionario que traduce alias cortos y estables a clases. Lo declaras una vez, en el boot de AppServiceProvider:

php
use Illuminate\Database\Eloquent\Relations\Relation;

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

A partir de ahí, la columna guarda 'post' en lugar de App\Models\Post. Más corto, más legible en la base de datos y —lo esencial— desacoplado de tu namespace: reorganiza el código cuanto quieras, que las filas siguen siendo válidas.

Dos ayudantes que agradecerás

$post->getMorphClass() te da el alias ('post') de un modelo, y Relation::getMorphedModel('post') hace el camino inverso y te devuelve la clase. Son útiles al escribir seeders, migraciones de datos o cualquier consulta cruda donde necesites el valor de _type sin acoplarte al nombre de la clase.

El problema N+1 y cómo esquivarlo

Las relaciones polimórficas son terreno fértil para el temido N+1: recorres 50 comentarios y, si accedes a $comment->commentable dentro del bucle sin precargarlo, disparas 50 consultas extra. La carga ansiosa (eager loading) lo resuelve, pero el lado morphTo tiene un matiz: como cada fila puede apuntar a un modelo distinto, Laravel agrupa por tipo y lanza una consulta por cada tipo presente.

php
// Precarga el dueño de cada comentario en el mínimo de consultas
$comments = Comment::with('commentable')->get();

Cuando además necesitas relaciones anidadas del dueño —los comentarios de la publicación, las etiquetas del video— usa morphWith dentro de la restricción, indicando qué cargar por tipo:

php
use Illuminate\Database\Eloquent\Relations\MorphTo;

$activities = ActivityFeed::with(['parentable' => function (MorphTo $morphTo) {
    $morphTo->morphWith([
        Post::class  => ['comments'],
        Video::class => ['tags'],
    ]);
}])->get();

Y si quieres blindar el lado morphMany contra el N+1 desde la propia definición, Laravel trae chaperone(), que reasigna a cada hijo su padre ya cargado:

php
public function comments(): MorphMany
{
    return $this->morphMany(Comment::class, 'commentable')->chaperone();
}

Consultar por el tipo del dueño

Para filtrar por características del padre polimórfico sin cargarlo, tienes whereHasMorph. Por ejemplo, comentarios cuyo dueño (sea publicación o video) tenga un título que empiece por «code»:

php
use Illuminate\Database\Eloquent\Builder;

$comments = Comment::whereHasMorph(
    'commentable',
    [Post::class, Video::class],
    fn (Builder $query) => $query->where('title', 'like', 'code%'),
)->get();

Su pariente whereMorphedTo('commentable', $post) restringe a un dueño concreto, y el comodín '*' recorre todos los tipos.

El modelo físico y el recorrido en Laravel

Con la teoría en la mano, dos diagramas terminan de aterrizarla. El primero es el modelo físico: cómo se ven realmente las tablas. Fíjate en que las flechas hacia los padres son punteadas —representan el par (tipo, id), no una clave foránea del motor.

Modelo físico de datos: las tablas posts y videos son los padres; comments e images llevan cada una un par de columnas polimórficas (_type, _id) con su índice compuesto; tags se relaciona con la tabla pivote taggables que también lleva el par polimórfico; las asociaciones polimórficas se dibujan punteadas para indicar que no son claves foráneas reales en la base de datos

El segundo muestra el recorrido dentro de Laravel cuando pides $comment->commentable: del controlador al modelo, del modelo a MorphTo, y de ahí al morph map que traduce el _type a la clase correcta antes de consultar la tabla.

Diagrama de arquitectura de Laravel: una petición HTTP entra por una ruta al CommentController, que accede a la relación commentable del modelo Comment; MorphTo lee las columnas commentable_type y commentable_id, el morph map traduce el alias 'post' o 'video' a la clase Post o Video, y finalmente Eloquent consulta la tabla correspondiente en la base de datos

Cuándo NO usar una tabla polimórfica

Aquí es donde separo el tutorial del criterio, porque este patrón tiene detractores con argumentos serios. En su libro SQL Antipatterns, Bill Karwin dedica un capítulo entero a las asociaciones polimórficas y las clasifica como un antipatrón a nivel de base de datos, por la razón que ya adelantamos: rompen la integridad referencial. Al no haber una clave foránea real, nada en el motor impide que quede un commentable_id apuntando a una publicación que ya se borró —un huérfano silencioso.

No es para asustarse ni para prohibirlas; es para elegir con los ojos abiertos.

Úsalas cuando…

El comportamiento es genuinamente transversal y de bajo riesgo de integridad: comentarios, «me gusta», etiquetas, imágenes adjuntas, notificaciones, bitácoras de actividad. Añadir un nuevo dueño debe costar una línea, no una tabla.

Piénsatelo dos veces si…

La relación es crítica para el negocio y necesitas que la base de datos garantice la integridad (facturación, contabilidad, inventario). Ahí una clave foránea de verdad vale más que la comodidad, y las alternativas de Karwin —tablas de intersección por tipo o columnas exclusivas— encajan mejor.

Reglas para convivir con el antipatrón

Si decides usarlas —y en un CMS o una red social casi siempre vale la pena—, protégete: fija siempre el morph map para desacoplar del namespace, mantén el índice compuesto, limpia los huérfanos con eventos de modelo (deleting) o tareas programadas, y no las metas donde un descuadre de integridad cueste dinero. Con esas cuatro barandillas, el intercambio sale a tu favor.

Cierre

Las relaciones polimórficas no son magia ni pecado: son un intercambio deliberado. Cambias la garantía de integridad del motor por una flexibilidad enorme de modelado, y a cambio de esas dos columnas humildes —_type y _id— dejas de duplicar tablas cada vez que una funcionalidad transversal aparece. Laravel 13 las expone con una API limpia y tipada; la parte difícil no es escribir morphMany, sino saber cuándo el problema pide esta herramienta y cuándo pide una clave foránea de toda la vida. Ahora tienes ambas cosas.

¿Diseñando el esquema de tu próximo proyecto?

Si estás decidiendo entre polimorfismo, tablas separadas o un modelo híbrido —y quieres que la decisión aguante el crecimiento— hablemos. Puedes ver cómo trabajo en la página de servicios.


Fuentes

Foto de Marco Torres

Escrito por

Marco Torres

Desarrollador Full-Stack senior con DevOps y arquitectura cloud, y Bachiller en Ingeniería de Sistemas. Escribo sobre arquitectura escalable y las lecciones de llevar sistemas a producción — desde la trinchera.