Desarrollo Web 5-8 minutos

Laravel 13: Relaciones polimórficas de Eloquent — morphTo, morphMany y morphToMany

Diego Cortés
Diego Cortés
Full Stack Developer & SEO Specialist
Compartir:
Laravel 13: Relaciones polimórficas de Eloquent — morphTo, morphMany y morphToMany
Imagen generada con IA

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.

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 fila

One-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.

Categorías