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.
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
Polymorphic table structure
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










