Programmers aiming for simple, concise code often work to remove conditionals. But when we’re programming complex business logic and edge cases, especially when that logic interacts with varied database models and relationships, it’s often tough to remove them without introducing some new pattern.
Polymorphic relationships are such a pattern—a powerful tool that can help us avoid complicated code paths when we’re dealing with similar related items.
Wikipedia defines polymorphism as the provision of a single interface to entities of different types.
Luckily, Laravel offers support for polymorphic database structures and model relationships. In this post, we’ll pick up where the documentation leaves off by presenting several patterns that provide us the opportunity to eliminate conditionals.
Let’s start with the one-to-one example outlined in the documentation. For a quick refresh, a blog post has one image, and a user has one image, and each image belongs to only one (either blog post or user).
After generating the database and model structure, we have the following tables: posts
, users
, and images
.
We also need to grab the Image
model from the docs:
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model;use Illuminate\Database\Eloquent\Relations\MorphTo; class Image extends Model{ public function imageable(): MorphTo { return $this->morphTo(); }}
Let’s say we have a page displaying all of the images on our site; each image will have a caption below it. For images attached to a user, we’ll display this caption: {$user->name}’s email was verified on {$user->email_verified_at}
.
For images attached to a post, we’ll display this one: {$post->name} was posted on {$post->created_at}
.
Once we have a collection of $images
, we can loop through them and call the imageable
relationship to get the thing the image is attached to. Now we have a decision to make. We know our imageable
is either a User
or a Post
, but since there are two different ways to display the caption, we might be inclined to check the type in our view.
@foreach ($images as $image) @if ($image->imageable instanceOf App\Models\User::class) {{ $image->imageable->name }}’s email was verified on {{ $image->imageable->email_verified_at->format('n/j/Y') }} @elseif ($image->imageable instanceOf App\Models\Post::class) {{ $image->imageable->name }} was posted on {{ $image->imageable->created_at->format('n/j/Y') }} @endif@endforeach
Unfortunately, we have just leaked our abstraction by exposing the model types to the view. Furthermore, whenever another model becomes imageable, we’ll need to add another condition to this if
block.
We can hide these model-specific implementation details by extracting caption
methods to the User
and Post
models.
class User extends Authenticatable{ public function caption(): string { return str('{name}\'s email was verified on {date}') ->replace('{name}', $this->name) ->replace('{date}', $this->email_verified_at->format('n/j/Y')); }}
class Post extends Model{ public function caption(): string { return str('{name} was posted on {date}') ->replace('{name}', $this->name) ->replace('{date}', $this->created_at->format('n/j/Y')); }}
Now we can clean up our view:
@foreach ($images as $image)- @if ($image->imageable instanceOf App\Models\User::class)- {{ $image->imageable->name }}’s email was verified on {{ $image->imageable->email_verified_at->format('n/j/Y') }}- @elseif ($image->imageable instanceOf App\Models\Post::class)- {{ $image->imageable->name }} was posted on {{ $image->imageable->created_at->format('n/j/Y') }}- @endif + {{ $image->imageable->caption }} @endforeach
Now that we’ve plugged the leak in our abstraction, there are a few patterns that I’d like to introduce to help keep things encapsulated as we introduce additional types.
First, our caption
method has helped us see something that these models have in common. Post
s and User
s can have images attached to them—as the imageable
relationship implies, they are imageable. Let’s make this official with an Imageable
contract.
<?php namespace App\Contracts; use Illuminate\Database\Eloquent\Relations\MorphOne; interface Imageable{ public function image(): MorphOne; public function caption(): string;}
-class User extends Authenticatable +class User extends Authenticatable implements Imageable { // ... }
-class Post extends Model +class Post extends Model implements Imageable { // ... }
As new polymorphic types implement the Imageable
contract, we’ll be required to implement any missing caption
methods.
In addition, we now have a type hint we can add to methods that depend on an Imageable
object. Let’s say our app has a Team
model that can feature Imageable
items to display on a team page. The method might look something like this:
class Team extends Model{ public function feature(Imageable $imageable) { $this->features()->save($imageable); }}
The feature
method doesn’t need to know or care what type of object $imageable
is as long as it implements the Imageable
contract.
Finally, using $image->imageable->caption()
could be improved. Treating the image as having the caption, which can be derived from its Imageable
object, would be a more readable alternative.
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo; class Image extends Model { public function imageable(): MorphTo { return $this->morphTo(); }+ + public function caption(): string+ {+ return $this->imageable->caption();+ } }
Now, our view looks a bit more readable:
@foreach ($images as $image)- {{ $image->imageable->caption() }} + {{ $image->caption() }} @endforeach
Now, let’s move on to many-to-many relationships. Again, we’ll start with the examples in Laravel’s documentation; in this example, both posts and videos can be associated with tags, and tags can be associated with many posts and/or videos.
Per the documentation, we’ll add tables for videos
, tags
, and taggables
.
<?php namespace App\Models; use App\Models\Post;use App\Models\Video;use Illuminate\Database\Eloquent\Factories\HasFactory;use Illuminate\Database\Eloquent\Model;use Illuminate\Database\Eloquent\Relations\MorphToMany; class Tag extends Model{ use HasFactory; public function posts(): MorphToMany { return $this->morphedByMany(Post::class, 'taggable'); } public function videos(): MorphToMany { return $this->morphedByMany(Video::class, 'taggable'); }}
The Image
model from our one-to-one example has an imageable
relationship to get the imaged thing, but the Tag
model currently provides no way to get the tagged things as a single collection.
We could add a taggables
method to merge the posts
and videos
collections:
<?php namespace App\Models; use App\Models\Post; use App\Models\Video; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphToMany; class Tag extends Model { use HasFactory; public function posts(): MorphToMany { return $this->morphedByMany(Post::class, 'taggable'); } public function videos(): MorphToMany { return $this->morphedByMany(Video::class, 'taggable'); }+ + public function taggables()+ {+ return $this->posts->append($this->videos->toArray());+ } }
However, there are two problems with this approach.
imageable
, taggables
doesn’t return a relationship. You can’t eager load it, or chain query methods off of it, or call it as a property such as: $tag->taggables
At this point we might be tempted to make a Taggable
model for the taggables
table so we can relate to it from Tag
.
public function taggables(): HasMany {- return $this->posts->append($this->videos->toArray()); + return $this->hasMany(Taggable::class); }
The problem with this approach is taggables
doesn’t actually return the tagged things. It returns a mapping to the tagged things via the taggable_id
and taggable_type
columns but not the things themselves.
We really want to replicate the pattern introduced in the Imageable
model by having the taggables
relationship return the things that implement that contract. This results in returning a mixed collection of posts
and videos
.
Note: It might seem strange to have a collection of more than one model type, but remember that we’re keeping this detail encapsulated. Calling code should only be aware that it is a collection of taggables.
So how in the world do we do this?
Jonas Staudenmeir wrote a fantastic laravel-merged-relations package which adds support for representing related data from multiple tables as a single SQL View. After installing the package, we need to make and run the following migration:
use App\Models\Tag;use Illuminate\Database\Migrations\Migration;use Staudenmeir\LaravelMergedRelations\Facades\Schema; return new class extends Migration{ public function up(): void { Schema::createMergeView( 'all_taggables', [(new Tag)->posts(), (new Tag)->videos()] ); } public function down(): void { Schema::dropView('all_taggables'); }};
Now, we can import the HasMergedRelationships
trait and update our taggables
relationship.
<?php namespace App\Models; use App\Models\Post; use App\Models\Video; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphToMany;+use Staudenmeir\LaravelMergedRelations\Eloquent\HasMergedRelationships; class Tag extends Model { use HasFactory;+ use HasMergedRelationships; public function posts(): MorphToMany { return $this->morphedByMany(Post::class, 'taggable'); } public function videos(): MorphToMany { return $this->morphedByMany(Video::class, 'taggable'); } public function taggables() {- return $this->posts->append($this->videos->toArray()); + return $this->mergedRelation('all_taggables'); } }
We can test this relationship with the following simple test:
/** @test */public function fetching_tagged_items(){ $tag = Tag::factory()->create(); $post = Post::factory()->tagged($tag)->create(); $video = Video::factory()->tagged($tag)->create(); $taggables = $tag->taggables()->get(); $this->assertTrue($taggables->contains($post)); $this->assertTrue($taggables->contains($video));}
Note: In the above example, both the
PostFactory
andVideoFactory
classes contain the following helpful method:
public function tagged(Tag $tag){ return $this->afterCreating(fn ($model) => $model->tags()->attach($tag));}
So far, we have covered working with a single type stored in different tables. Next, we’ll consider how to store multiple types in a single table. This pattern is called Single Table Inheritance, and the Parental package was created to implement it in Laravel.
To keep things simple and build from our previous examples, let’s say we need to distinguish between guest posts and sponsored posts. We’ll add the following migration to store the guest and sponsor data.
Note: These would probably be foreign keys of some type; however, we’ll use strings for this example.
public function up(): void{ Schema::table('posts', function (Blueprint $table) { // Defines the type of each post, "guest" or "sponsored" $table->string('type')->after('id'); // Could theoretically store guest and sponsor data $table->string('guest')->nullable(); $table->string('sponsor')->nullable(); });}
Now we can make GuestPost
and SponsoredPost
models to cover the two types and update the Post
model to define its child types with Parental.
<?php namespace App\Models\Posts; use App\Models\Post;use Parental\HasParent; class GuestPost extends Post{ use HasParent;}
<?php namespace App\Models\Posts; use App\Models\Post;use Parental\HasParent; class SponsoredPost extends Post{ use HasParent;}
<?php namespace App\Models; use App\Contracts\Imageable; use App\Contracts\Taggable; use App\Models\Image;+use App\Models\Posts\GuestPost; +use App\Models\Posts\SponsoredPost; use App\Models\Tag; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\Relations\MorphToMany;+use Parental\HasChildren; class Post extends Model implements Imageable, Taggable { use HasFactory;+ use HasChildren; + + protected $guarded = [];+ + protected $childTypes = [+ 'guest' => GuestPost::class,+ 'sponsored' => SponsoredPost::class,+ ]; // ... }
The above will result in another mixed collection when calling Post::all()
.
Note: Polymorphism is all about encapsulating what differs by abstracting what is the same. In our one-to-one and many-to-many examples, we defined our models’ sameness first by extracting an interface and next by merging our relationships. Here, however, all of our models are posts. Where they differ is in what type of post they are.
Now that we have our mixed collection, let’s say we want to have a line under each post title crediting the source of the post. The guest posts would read This post was guest written by {guest name}
while the sponsored posts read This post is sponsored by {sponsor name}
. This is as simple as defining a credits
method on both models.
<?php namespace App\Models\Posts; use App\Models\Post; use Parental\HasParent; class GuestPost extends Post { use HasParent;+ + public function credits()+ {+ return "This post was guest written by {$this->guest}";+ } }
<?php namespace App\Models\Posts; use App\Models\Post; use Parental\HasParent; class SponsoredPost extends Post { use HasParent;+ + public function credits()+ {+ return "This post is sponsored by {$this->sponsor}";+ } }
Note: If we want to enforce the credits method, we could implement a
Post
contract, similar to how we did with theImageable
andTaggable
contracts.
Whatever varying conditionals you find yourself working with, patterns can be applied to treat them all as if they were the same. I hope this post inspires you to replace a few conditionals with polymorphism.
We appreciate your interest.
We will get right back to you.