Relaciones polimórficas en Laravel 13: una tabla para muchos dueños
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.
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.
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.
// 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');
}
}
$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.
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.
$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:
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:
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.
// 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:
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:
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»:
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.
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.
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.
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.
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.
Fuentes
Artículos relacionados
Novedades de Laravel 13: atributos PHP, AI SDK y passkeys
Qué trae Laravel 13 (17 de marzo de 2026) y por qué es el upgrade más suave de la historia del framework: atributos PHP 8 para declarar modelos, jobs y comandos; el AI SDK ya de primera clase y agnóstico de proveedor; login sin contraseña con passkeys; Reverb con driver de base de datos para tiempo real sin Redis; Cache::touch(); y una guía de migración con cero cambios que rompen.
De un reto técnico a un SPA full-stack: Angular 19, Express y Firebase
Caso de estudio: cómo resolví un reto técnico full-stack —una app de tareas con login, CRUD y estado— con Angular 19, un API Express + TypeScript en Cloud Functions y Firestore, aplicando arquitectura hexagonal, SOLID y buenas prácticas.
