SC header logo

Simplifying Many-to-Many Relationships with Laravel Polymorphic Relations

Laravel's polymorphic relations simplify many-to-many setups by collapsing multiple pivot tables into one. They also cost you foreign key constraints and add a few sharp edges. This guide covers the setup, the tradeoffs, and the patterns that hold up in production.

Author

Bruno

CategoryDevelopment
Last updated

06.05.2026.

In database architecture, many-to-many relationships often add complexity to our system. One way to tackle this is by using polymorphic relations, which Laravel gracefully handles. This blog post shows how to use Laravel's polymorphic relations to simplify many-to-many relationships.

We will use the scenario where a user can "like" an item in an online store. To achieve this, we typically need user, item, and pivot tables. This structure can prove challenging, especially when adding more likeable types to the system. Fortunately, with polymorphic relations, we can consolidate all these relationships into one table, simplifying the process of adding new likeable types to the system.

The following illustrations compare a non-polymorphic table structure with a polymorphic table structure:

Non-polymorphic table structure

many-to-many-polymorphic-example---many-to-many

Polymorphic table structure

many-to-many-polymorphic-example---polymorphic

The latter structure is simpler and more reusable. When we want to introduce a new likeable type to the system, we only need to create a table for the model and add internal relationships within Laravel. We can forgo the extra steps of creating multiple pivot tables.

In this blog post, we won't cover the entire process of creating tables, models, migrations, etc. Instead, we'll focus on the key steps to make the transition to polymorphic relationships.

You can find a full working example on our GitHub repository.

When polymorphic isn't the right answer

Before adopting polymorphic relations everywhere, it helps to know what you're giving up.

You lose foreign key constraints across the polymorphic side. The likeable_id column points to different tables depending on likeable_type, so the database can't enforce that the row exists. If you delete a sweet, the corresponding rows in likeables stay behind unless you handle cleanup yourself. Standard cascade rules don't apply.

Referential integrity becomes your application's job. That means model events or scheduled cleanup jobs to handle deletions. Easy to forget on a small project, painful when you find out two years later.

Querying outside Eloquent gets harder too. A reporting query or a raw join across all liked types needs a UNION or per-type joins. With separate pivot tables you'd just join one table.

Polymorphic shines when the relations behave the same way across types. Likes, comments, tags, attachments. If the relation semantics differ a lot per parent model, separate pivots keep things explicit and easier to reason about.

Code Examples & Definitions

Before we proceed, it's worth mentioning that we have a working example of the Laravel setup on our GitHub. This provides a practical context to understand the code examples and definitions we'll discuss.

It's good practice to rename the morph relationship to something other than the model namespace - a simple string would suffice. In the boot method of the AppServiceProvider, add a morph map that specifies the class used. By doing this, we decouple the names from the application's internal structure.

Relation::enforceMorphMap([
    'book' => Book::class,
    'item' => Item::class,
    'sweet' => Sweet::class,
]);

You can read more about this in the Laravel documentation here.

Models

Sweet/Book/Item Model relationships

After creating the necessary models, we must define the polymorphic relationship within them. In this case, we're focusing on the likeables table. This definition must be added to all models that will be "likeable" by the user.

public function likes()
{
	// omit Model name with the one you are using
    return $this->morphToMany(User::class, 'likeable');
}

In our case, all models share the same relationship.

You can find the final Sweet, Book, Item models here:

User Model relationships

We also need to define additional relationships for all models that the user can like inside the User model.

// Sweets relation
public function likedSweets(): MorphToMany
{
    return $this->morphedByMany(Sweet::class, 'likeable');
}

// Items relation
public function likedItems(): MorphToMany
{
    return $this->morphedByMany(Item::class, 'likeable');
}

// Books relation
public function likedBooks(): MorphToMany
{
    return $this->morphedByMany(Book::class, 'likeable');
}

To fetch all items liked by the user, we add a hasMany relationship.

// All likes of this user
public function likes(): HasMany
{
    return $this->hasMany(Likeable::class);
}

The final User Model can be found at this link.

Likeable Model

The Likeable model should be created with a belongsTo relation to the user. This way, we can retrieve all items liked by a user when needed.

public function user(): BelongsTo
{
  return $this->belongsTo(User::class);
}

Controllers

Sweet/Book/ItemController Controllers

The like controllers will be identical, as their goal is to fetch the template we're using and count the items liked by the user.

Take the BookController as an example, and add the index method inside.

// BookController.php
public function index()
{
  // Just count number of likes here
  return view('books', [
      'books' => Book::withCount('likes')->get()
  ]);
}

You can apply the same logic to all other controllers for likeables - just replace the model name used.

You can find them here:

LikeController

The Like Controller is responsible for adding or removing likes from items. Create a store method that is triggered via a POST request.

Below is an example of a method responsible for adding the necessary relationships to a pivot table. This method finds the model passed from the request as the model type and tries to find that model_id. If we have defined all relationships between the models correctly, we can use the attach & detach method to add or remove relationships from the pivot table.

// LikeController.php
public function store(LikeableRequest $request): RedirectResponse
{
  $validated = $request->validated();

  $likeable = $validated['model_type']::findOrFail($validated['model_id']);

  $likeable->likes()->where('user_id', auth()->id())->exists() ?
      $likeable->likes()->detach(auth()->id()):
      $likeable->likes()->attach(auth()->id());

  return redirect()->back();
}

Views

Let's take a look at a short example of a form-action view. It passes the model ID and modelType to the controller.

// books.blade.php
<form action="{{ route('like', ['model_type' => \App\Models\Book::class, 'model_id' => $book->id]) }}" method="POST">
  @csrf
  <button type="submit" class="bg-gray-200">
      @if(auth()->user()->likes()->where('likeable_id', $book->id)->where('likeable_type', 'book')->first())
          liked
      @else
          like
      @endif
  </button>
</form>

You can find the full code at this link.

Seeders

Lastly, we have a simple seeder for creating random users and adding polymorphic relationships.

Check the Database\Seeders\UserSeeder here.

public function likeRandomly()
{
    $users = User::all();
    $sweets = Sweet::factory()->count(10)->create();
    $items = Item::factory()->count(10)->create();
    $books = Book::factory()->count(10)->create();

    foreach ($users as $user) {
        for ($i = 0; $i < random_int(1, 10); $i++) {
            $user->likedSweets()->attach($sweets->random());
            $user->likedItems()->attach($items->random());
            $user->likedBooks()->attach($books->random());
        }
    }
}

Beyond the basics

The original code works, but a few refinements are worth knowing about once you have the basics in place.

Toggling without the if/else

The LikeController above checks for an existing like, then either attaches or detaches. Laravel ships with a toggle() method that does both in one call:

public function store(LikeableRequest $request): RedirectResponse
{
    $validated = $request->validated();

    $likeable = $validated['model_type']::findOrFail($validated['model_id']);
    $likeable->likes()->toggle(auth()->id());

    return redirect()->back();
}

Same behaviour, half the code. The method returns an array showing what got attached and detached, which is handy if you need to fire events or update a counter cache afterwards.

Indexing the morph columns

The likeables table has two columns the database queries on every lookup: likeable_type and likeable_id. Eloquent's morph migration helper ($table->morphs('likeable')) adds an index on this pair automatically. If you wrote the migration manually, double-check that the composite index exists.

Without it, every query like where('likeable_type', 'book')->where('likeable_id', 5) does a full table scan. Fine at 1,000 rows. Fatal at a million.

Adding pivot data

One of the strongest reasons to use polymorphic many-to-many is that the pivot table can carry extra columns that apply to every parent type. Want to know when a like was created? Add timestamps() to the migration and call withTimestamps() on the relation:

public function likes()
{
    return $this->morphToMany(User::class, 'likeable')->withTimestamps();
}

Want to support different reaction types (like, love, bookmark, save) instead of a binary like? Add a reaction column to the pivot, then declare it on the relation:

public function likes()
{
    return $this->morphToMany(User::class, 'likeable')
        ->withPivot('reaction')
        ->withTimestamps();
}

You'd have needed three new pivot tables and three new columns to do the same thing without polymorphic.

Eager loading across types

withCount('likes') handles the count case cleanly. Loading the actual users who liked something works the same as any other relation:

$books = Book::with('likes')->get();

The interesting case is the inverse: loading all liked items for a user. Because the items live in different tables, you can't eager load them in a single query. Load each relation separately:

$user->load(['likedBooks', 'likedSweets', 'likedItems']);

This is one of the genuine costs of polymorphic. Three queries instead of one. Usually fine, occasionally a reason to denormalize.

Common pitfalls

Four things developers run into after putting polymorphic relations into production:

The morph map you didn't add

Without Relation::enforceMorphMap(), Laravel stores the full class name in likeable_type. Rename a namespace or move a model, and every existing row points to a class that no longer exists. The fix at that point is a data migration. Add the morph map on day one, even if you only have one likeable type.

Orphaned pivot rows

Deleting a Book doesn't clean up its rows in likeables. The database can't cascade because there's no foreign key. Handle this in the model's deleting event or in a periodic cleanup job. Whichever you pick, write it down somewhere obvious.

The missing composite index

Already covered above, but it earns a second mention here. Run your migrations through a checklist before they hit production. Every morph table needs (likeable_type, likeable_id) as a composite index. The default morphs() helper does this. Manual schemas often don't.

wherePivot for extra pivot columns

Once you add withPivot('reaction') to filter by reaction type, queries like ->likes()->where('reaction', 'love') look right but reference the wrong table. Use wherePivot('reaction', 'love') instead. The error messages aren't always obvious about why the query comes back empty.

FAQ

Conclusion

By using Laravel's polymorphic relationships, you can simplify your many-to-many relationships. The likeable example we explored today shows how polymorphic relations can streamline your code, making it cleaner and more manageable. Use the pattern where the relation semantics fit, and fall back to separate pivots where they don't.

Enjoy building and keep coding!

Articles You Might Like

evolution-mockup
Bespoke software development: a practical guide for business owners
Development
April 23, 2025

Frustrated by generic tools? Learn how bespoke software solves real business problems and supports growth....

robert

Robert,

CEO

storyblok-vs-wordpress
Storyblok vs WordPress: Which CMS is Best for Your Website?
Development
December 20, 2024

Discover the key differences between Storyblok and WordPress to choose the perfect CMS for your project....

matej

Matej,

Software Developer

desktop-mobile-person
How Progressive Web Apps (PWAs) Are Transforming the Digital Experience
Development
October 02, 2024

Discover how Progressive Web Apps (PWAs) can improve user engagement and boost business growth....

robert

Robert,

CEO

svelte-5
Svelte 5 - A magical revolution
Development
August 26, 2024

Explore how Svelte 5 revolutionizes web development with runes and enhanced reactivity for faster apps....

renato

Renato,

JavaScript Lead

website-hacker
Why Websites Are Hacked and How to Protect Yours
Development
August 07, 2024

Learn why websites get hacked and how to protect yours with practical security measures and best practices....

robert

Robert,

CEO

computer-code-editor
Popular Node.js Backend Frameworks in 2024
Development
July 24, 2024

Wondering what the top Node.js frameworks in 2024 are? Read on to discover the best options for your project!...

robert

Robert,

CEO

frustrated-person
Speed Up Your Website in 10 Easy Steps
Development
July 16, 2024

Is your website too slow? Here are 10 practical ways to improve performance and keep visitors engaged....

robert

Robert,

CEO

Client CTA background image

A new project on the way? We’ve got you covered.