Laravel 13 Eloquent Polymorphic Relationships: morphTo, morphMany, and morphToMany
With Laravel 13's polymorphic relationships, a single comments table serves posts, videos, and any future model: two columns — commentable_type and commentable_id — point to the owner of each row, and morphMany/morphTo do the rest without duplicating schema.
What Polymorphic Relationships Are and When to Use Them
A polymorphic relationship lets a model belong to more than one type of model within a single association. It is Eloquent's answer to the classic "the same thing for several models" problem: comments, reactions, tags, attachments, or activity logs that need to work identically across different entities.
The Problem: Comments on Posts and Videos Without Duplicating Tables
Without polymorphism, a comment that serves both posts and videos forces you to choose between two evils: creating a comments table for each model (comments_posts, comments_videos) or adding extra nullable columns to a generic table. The first option multiplies code and migrations; the second fills the database with empty columns. Polymorphism solves both with a single schema.
The Two Magic Columns: commentable_type and commentable_id
Instead of a single post_id column, the polymorphic table stores two: commentable_type holds the class (or a mapped name) of the owner model, and commentable_id holds the identifier of the concrete record. Together they point to any model without intermediate tables: a row with type App\Models\Post and id 12 is a comment on post 12; one with App\Models\Video and id 7, a comment on video 7.
Read also
One-to-Many Polymorphic: morphMany and morphTo
This is the most common case: an owner model has many records of another model, and that other model belongs to owners of different types. The canonical example from the documentation is comments.
The comments Table Migration
Laravel includes a helper for these columns: nullableMorphs creates the type and id pair with the right indexes.
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->text('body');
$table->nullableMorphs('commentable');
$table->timestamps();
});Defining the Relationships on Post, Video, and Comment
The owner model declares morphMany with the relationship name, and the polymorphic model declares morphTo with that same name. Notice that Post and Video never know about each other: each one defines its own relationship to 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();
}
}Creating and Querying Comments with the Fluent API
From the owner side, the API is identical to a normal one-to-many relationship: you create the comment through the relationship and Eloquent fills both columns for you.
$post->comments()->create(['body' => 'First comment']);
$video->comments()->create(['body' => 'Another comment']);
$comments = $post->comments;
$owner = $comment->commentable; // Post or Video, depending on the rowOne-to-One Polymorphic: morphOne
When each owner has exactly one record of the other model, the one-to-one version uses morphOne on the owner and morphTo on the shared model.
Example: a Cover Image for Posts and Products
A single images table serves as the cover for posts and products: each row belongs to exactly one owner, and the owner accesses its image using the same naming convention.
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 Polymorphic: morphToMany and morphedByMany
The third flavor covers the case where many owners share many records: the relationship materializes in a pivot table with its own type columns.
Tags Shared Between Posts and Videos with the taggables Pivot Table
A tag can be on many posts and many videos, and each post or video can have many tags. The taggables pivot table stores tag_id, taggable_type, and 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');
}
}With this, tagging a post or a video and listing the tags of either one is as straightforward as in a classic many-to-many: $post->tags()->attach($tag) or $tag->posts.
Querying Polymorphic Relationships
Polymorphic relationships are queried with the same fluent API as the rest of Eloquent, but they add a superpower: filtering by the type of the owner inside the query itself.
whereHasMorph: Filtering by Type Inside the Relationship
whereHasMorph receives the relationship and a list of types, and lets you apply conditions that depend on the related model. The following example returns comments whose owner is a published Post or an active Video:
Comment::query()
->whereHasMorph('commentable', [Post::class, Video::class], function ($query, $type) {
$column = $type === Post::class ? 'published_at' : 'status';
$query->whereNotNull($column);
})
->get();Eager Loading on Polymorphics: morphedByMany and morphTo
Eager loading works exactly like on any relationship: with with('comments.commentable') you load the comments and their owner in two queries instead of one per row. If you have not applied it yet, the eager loading in Laravel 13 tutorial shows you how to eliminate the N+1 problem at its root.
Custom Polymorphic Types: morphMap and enforceMorphMap
By default, Eloquent stores the full PHP class name in the _type column. That works, but it couples your database to your class names: renaming Post to Article breaks existing records.
Why You Shouldn't Store the Full Class Name in the Database
If tomorrow you move App\Models\Post to another namespace or change the model's name, old rows keep pointing to a class that no longer exists, and queries fail or return wrong models. The morph map avoids that with short, stable aliases.
enforceMorphMap: Short, Stable, and Refactor-Proof Names
Relation::morphMap defines the aliases; Relation::enforceMorphMap additionally requires every morphed model to be mapped, throwing an exception if you forget one. It is registered in the boot method of AppServiceProvider:
use Illuminate\Database\Eloquent\Relations\Relation;
Relation::enforceMorphMap([
'post' => Post::class,
'video' => Video::class,
'product' => Product::class,
]);From that point on, the database stores 'post', 'video', or 'product' in the _type columns, and the code keeps using the PHP classes normally. Renaming a class no longer breaks anything.
Performance and Best Practices
Composite Index on the type and id Columns
Every polymorphic query filters by both columns at once: the type to know which model we are talking about and the id to locate the record. A composite index on (type, id) avoids full table scans on large tables; nullableMorphs and morphs already create it, but if you inherit a schema without it, add it in a new migration.
When NOT to Use Polymorphism
Polymorphism is not free: whereHasMorph queries are harder to index well, and the relationship type is lost at the database level (there are no real foreign keys). If an entity only relates to one model, use a normal relationship. Polymorphism shines when you know the list of owners will grow: comments, tags, likes, attachments, and activity are the classic cases. And like any relationship, polymorphics are tested the same way with Pest or PHPUnit: create the record with its correct type and verify the inverse query.
Conclusion
Eloquent's polymorphic relationships let you serve comments, tags, images, or likes to several models with a single table: morphMany and morphOne on the owner, morphTo on the shared model, and morphToMany with morphedByMany for the many-to-many case. Add the morph map from day one so your data does not depend on your class names, and a composite index on type and id so queries stay fast as the table grows. If you want to keep going deeper into Eloquent, this blog has guides on query scopes and advanced query builder that pair well with what you just learned.

