Web Development 5-8 minutes

Laravel 13 Eloquent Polymorphic Relationships: morphTo, morphMany, and morphToMany

Diego Cortés
Diego Cortés
Full Stack Developer & SEO Specialist
Share:
Laravel 13 Eloquent Polymorphic Relationships: morphTo, morphMany, and morphToMany
Image generated with AI

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.

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 row

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

Categories