Encapsulating Polymorphism

Feature image: Encapsulating Polymorphism

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.

1. One-to-One

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

The setup

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.

The patterns

First, our caption method has helped us see something that these models have in common. Posts and Users 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

2. Many to Many

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.

The setup

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.

  • First, this method will need to be updated every time a new taggable type is added.
  • Second, unlike 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?

The pattern

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 and VideoFactory classes contain the following helpful method:

public function tagged(Tag $tag)
{
return $this->afterCreating(fn ($model) => $model->tags()->attach($tag));
}

3. Single Table Inheritance

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.

The setup

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.

The pattern

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 the Imageable and Taggable contracts.

Closing

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.

Get our latest insights in your inbox:

By submitting this form, you acknowledge our Privacy Notice.

Hey, let’s talk.
©2024 Tighten Co.
· Privacy Policy