Laravel 13: Relaciones polimórficas de Eloquent — morphTo, morphMany y morphToMany
Con las relaciones polimórficas de Laravel 13, una sola tabla comments sirve para posts, videos y cualquier modelo futuro: dos columnas — commentable_type y commentable_id — apuntan al dueño de cada fila, y morphMany/morphTo hacen el resto sin duplicar esquema.
Qué son las relaciones polimórficas y cuándo usarlas
Una relación polimórfica permite que un modelo pertenezca a más de un tipo de modelo dentro de una sola asociación. Es la respuesta de Eloquent al problema clásico de "lo mismo para varios modelos": comentarios, reacciones, etiquetas, adjuntos o registros de actividad que deben funcionar igual en entidades distintas.
El problema: comentarios en posts y videos sin duplicar tablas
Sin polimorfismo, un comentario que sirve para posts y para videos te obliga a elegir entre dos males: crear una tabla comments para cada modelo (comments_posts, comments_videos) o añadir columnas nullable de más a una tabla genérica. La primera opción multiplica el código y las migraciones; la segunda llena la base de datos de columnas vacías. El polimorfismo resuelve ambos con un solo esquema.
Las dos columnas mágicas: commentable_type y commentable_id
En lugar de una columna post_id, la tabla polimórfica guarda dos: commentable_type almacena la clase (o un nombre mapeado) del modelo dueño, y commentable_id guarda el identificador del registro concreto. Juntas apuntan a cualquier modelo sin tablas intermedias: una fila con type App\Models\Post e id 12 es un comentario del post 12; una con App\Models\Video e id 7, del video 7.
Lee también
One-to-many polimórfica: morphMany y morphTo
Es el caso más habitual: un modelo dueño tiene muchos registros de otro modelo, y ese otro modelo pertenece a dueños de tipos distintos. El ejemplo canónico de la documentación es el de los comentarios.
La migración de la tabla comments
Laravel incluye un helper para estas columnas: nullableMorphs crea el par type e id con los índices adecuados.
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->text('body');
$table->nullableMorphs('commentable');
$table->timestamps();
});Definir las relaciones en Post, Video y Comment
El modelo dueño declara morphMany con el nombre de la relación, y el modelo polimórfico declara morphTo con ese mismo nombre. Fíjate en que Post y Video no se conocen entre sí: cada uno define su propia relación hacia Comment.
class Post extends Model
{
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable');
}
}
class Video extends Model
{
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable');
}
}
class Comment extends Model
{
public function commentable(): MorphTo
{
return $this->morphTo();
}
}Crear y consultar comentarios con la API fluida
Desde el lado del dueño, la API es idéntica a una relación uno-a-muchos normal: creas el comentario a través de la relación y Eloquent rellena las dos columnas por ti.
$post->comments()->create(['body' => 'Primer comentario']);
$video->comments()->create(['body' => 'Otro comentario']);
$comments = $post->comments;
$owner = $comment->commentable; // Post o Video, según la filaOne-to-one polimórfica: morphOne
Cuando cada dueño tiene exactamente un registro del otro modelo, la versión uno-a-uno usa morphOne en el dueño y morphTo en el modelo compartido.
Ejemplo: una imagen de portada para posts y productos
Una sola tabla images sirve de portada a posts y productos: cada fila pertenece a un único dueño, y el dueño accede a su imagen con la misma convención de nombres.
class Post extends Model
{
public function cover(): MorphOne
{
return $this->morphOne(Image::class, 'imageable');
}
}
class Product extends Model
{
public function cover(): MorphOne
{
return $this->morphOne(Image::class, 'imageable');
}
}
class Image extends Model
{
public function imageable(): MorphTo
{
return $this->morphTo();
}
}Many-to-many polimórfica: morphToMany y morphedByMany
El tercer sabor cubre el caso de que muchos dueños compartan muchos registros: la relación se materializa en una tabla pivote con sus propias columnas de tipo.
Tags compartidos entre posts y videos con la tabla pivote taggables
Un tag puede estar en muchos posts y muchos videos, y cada post o video puede tener muchos tags. La tabla pivote taggables guarda tag_id, taggable_type y taggable_id.
Schema::create('taggables', function (Blueprint $table) {
$table->id();
$table->foreignId('tag_id');
$table->morphs('taggable');
});
class Post extends Model
{
public function tags(): MorphToMany
{
return $this->morphToMany(Tag::class, 'taggable');
}
}
class Tag extends Model
{
public function posts(): MorphedByMany
{
return $this->morphedByMany(Post::class, 'taggable');
}
public function videos(): MorphedByMany
{
return $this->morphedByMany(Video::class, 'taggable');
}
}Con esto, etiquetar un post o un video y listar los tags de cualquiera de ellos es tan directo como en una many-to-many clásica: $post->tags()->attach($tag) o $tag->posts.
Consultar relaciones polimórficas
Las relaciones polimórficas se consultan con la misma API fluida que el resto de Eloquent, pero añaden un superpoder: filtrar por el tipo del dueño dentro de la propia consulta.
whereHasMorph: filtrar por tipo dentro de la relación
whereHasMorph recibe la relación y una lista de tipos, y permite condiciones que dependen del modelo relacionado. El siguiente ejemplo trae los comentarios cuyo dueño es un Post publicado o un Video en estado activo:
Comment::query()
->whereHasMorph('commentable', [Post::class, Video::class], function ($query, $type) {
$column = $type === Post::class ? 'published_at' : 'status';
$query->whereNotNull($column);
})
->get();Eager loading en polimórficas: morphedByMany y morphTo
El eager loading funciona igual que en cualquier relación: con with('comments.commentable') cargas los comentarios y su dueño en dos consultas en lugar de una por fila. Si no lo has aplicado todavía, el tutorial de eager loading en Laravel 13 te muestra cómo eliminar el problema N+1 de raíz.
Tipos polimórficos personalizados: morphMap y enforceMorphMap
Por defecto, Eloquent guarda el nombre completo de la clase PHP en la columna _type. Eso funciona, pero acopla tu base de datos a tus nombres de clase: renombrar Post a Article rompe los registros existentes.
Por qué no guardar el nombre completo de la clase en la base de datos
Si mañana mueves App\Models\Post a otro namespace o cambias el nombre del modelo, las filas viejas siguen apuntando a una clase que ya no existe y las consultas fallan o devuelven modelos equivocados. El morph map evita ese problema con alias cortos y estables.
enforceMorphMap: nombres cortos, estables y a prueba de refactors
Relation::morphMap define los alias; Relation::enforceMorphMap además exige que todo modelo morphed esté mapeado, lanzando una excepción si te olvidas de uno. Se registra en el método boot de AppServiceProvider:
use Illuminate\Database\Eloquent\Relations\Relation;
Relation::enforceMorphMap([
'post' => Post::class,
'video' => Video::class,
'product' => Product::class,
]);Desde ese momento la base de datos guarda 'post', 'video' o 'product' en las columnas _type, y el código sigue usando las clases PHP con normalidad. Renombrar una clase ya no rompe nada.
Rendimiento y buenas prácticas
Índice compuesto en las columnas type e id
Toda consulta polimórfica filtra por ambas columnas a la vez: el type para saber de qué modelo hablamos y el id para localizar el registro. Un índice compuesto en (type, id) evita escaneos completos de tabla en tablas grandes; nullableMorphs y morphs ya lo crean, pero si heredas un esquema sin él, añádelo en una migración nueva.
Cuándo NO usar polimorfismo
El polimorfismo no es gratis: las consultas con whereHasMorph son más difíciles de indexar bien y el tipo de la relación se pierde a nivel de base de datos (no hay foreign keys reales). Si una entidad solo se asocia a un modelo, usa una relación normal. El polimorfismo brilla cuando sabes que la lista de dueños va a crecer: comentarios, tags, likes, adjuntos y actividad son los casos clásicos. Y como cualquier relación, las polimórficas se testean igual con Pest o PHPUnit: crea el registro con su tipo correcto y verifica la consulta inversa.
Conclusión
Las relaciones polimórficas de Eloquent te dejan servir comentarios, tags, imágenes o likes a varios modelos con una sola tabla: morphMany y morphOne en el dueño, morphTo en el modelo compartido y morphToMany con morphedByMany para el caso muchos-a-muchos. Añade el morph map desde el primer día para que tus datos no dependan de los nombres de tus clases, y un índice compuesto en type e id para que las consultas sigan siendo rápidas cuando la tabla crezca. Si quieres seguir profundizando en Eloquent, en este blog tienes guías de query scopes y de query builder avanzado que combinan muy bien con lo que acabas de ver.

