Polymorphic Relations in Laravel: 8 Open-Source Practical Examples

Polymorphic Relations in Laravel: 8 Open-Source Practical Examples
Admin
Friday, September 27, 2024 5 mins to read
Share
Polymorphic Relations in Laravel: 8 Open-Source Practical Examples

Polymorphic relationships are one of the most complex relationships in Laravel. In this post, let's examine eight examples from Laravel open-source projects and how they use them.

By the end of this article, I hope you will see a general pattern of when to use polymorphic relationships.

The article is very long, so here's the Table of Contents:

  1. Polymorphic with Traits: Tags
  2. Polymorphic with Traits: Likes
  3. Same Relationship in Two Different Models
  4. Issues with Comments
  5. Get Model Type using Scopes
  6. One Model Has Many Polymorphic Relationships
  7. Polymorphic with UUID as Primary Key
  8. Reusable with Traits & Only One Type of Address

Let's dive in!


Example 1: Polymorphic with Traits: Tags

The first example is from an open-source project called laravelio/laravel.io. This project has more than one polymorphic relationship, but we will look at the tags example.

The relationship is described in a trait.

app/Concerns/HasTags.php:

use App\Models\Tag;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
 
trait HasTags
{
public function tags(): Collection
{
return $this->tagsRelation;
}
 
public function syncTags(array $tags)
{
$this->save();
$this->tagsRelation()->sync($tags);
 
$this->unsetRelation('tagsRelation');
}
 
public function removeTags()
{
$this->tagsRelation()->detach();
 
$this->unsetRelation('tagsRelation');
}
 
public function tagsRelation(): MorphToMany
{
return $this->morphToMany(Tag::class, 'taggable')->withTimestamps();
}
}

What's different here is that when you regularly define a relationship, this is defined in a method with a name of relationship and a suffix of Relation. Then, this method is called when in the actual relationship method.

This trait is used in two models, one of them is Article.

app/Models/Article.php:

use App\Concerns\HasTags;
use Illuminate\Database\Eloquent\Model;
use Spatie\Feed\Feedable;
 
final class Article extends Model implements Feedable
{
use HasAuthor;
use HasFactory;
use HasLikes;
use HasSlug;
use HasTags;
 
// ...
}

Then, when creating an article, the syncTags() method from the trait is used.

app/Jobs/CreateArticle.php:

use App\Events\ArticleWasSubmittedForApproval;
use App\Models\Article;
 
final class CreateArticle
{
// ...
 
public function handle(): void
{
$article = new Article([
'uuid' => $this->uuid->toString(),
'title' => $this->title,
'body' => $this->body,
'original_url' => $this->originalUrl,
'slug' => $this->title,
'submitted_at' => $this->shouldBeSubmitted ? now() : null,
]);
$article->authoredBy($this->author);
$article->syncTags($this->tags);
 
if ($article->isAwaitingApproval()) {
event(new ArticleWasSubmittedForApproval($article));
}
}
}

Example 2: Polymorphic with Traits: Likes

This example comes from the guillaumebriday/laravel-blog open-source project. Here, we have an example for the likes.

First, in this project, the likeable morphs database columns are nullable, and created using the nullableMorphs() Laravel helper.

database/migrations/# 2017_11_15_003340_create_likes_table.php:

Schema::create('likes', function (Blueprint $table) {
$table->increments('id');
$table->integer('author_id')->unsigned();
$table->foreign('author_id')->references('id')->on('users');
 
$table->nullableMorphs('likeable');
 
$table->timestamps();
});

Similar to the previous example, the relationship is described in a trait.

app/Concern/Likeable.php:

use App\Models\Like;
use Illuminate\Database\Eloquent\Relations\morphMany;
 
trait Likeable
{
protected static function bootLikeable(): void
{
static::deleting(fn ($resource) => $resource->likes->each->delete());
}
 
public function likes(): morphMany
{
return $this->morphMany(Like::class, 'likeable');
}
 
public function like()
{
if ($this->likes()->where('author_id', auth()->id())->doesntExist()) {
return $this->likes()->create(['author_id' => auth()->id()]);
}
}
 
public function isLiked(): bool
{
return $this->likes->where('author_id', auth()->id())->isNotEmpty();
}
 
public function dislike()
{
return $this->likes()->where('author_id', auth()->id())->get()->each->delete();
}
}

The trait is used in two models. Let's take a look at the Post Model.

app/Models/Post.php:

use App\Concern\Likeable;
use Illuminate\Database\Eloquent\Model;
 
class Post extends Model
{
use HasFactory, Likeable;
 
// ...
}

And finally, the methods for like or dislike from the trait are used in the Controller.

app/Http/Controllers/PostLikeController.php:

use App\Models\Post;
use Illuminate\Support\Str;
use Tonysm\TurboLaravel\Http\MultiplePendingTurboStreamResponse;
 
use function Tonysm\TurboLaravel\dom_id;
 
class PostLikeController extends Controller
{
public function store(Post $post): MultiplePendingTurboStreamResponse
{
$post->like();
 
return response()->turboStream([
response()->turboStream()->replace(dom_id($post, 'like'))->view('likes._like', ['post' => $post]),
response()->turboStream()->update(dom_id($post, 'likes_count'), Str::of($post->likes()->count()))
]);
}
 
public function destroy(Post $post): MultiplePendingTurboStreamResponse
{
$post->dislike();
 
return response()->turboStream([
response()->turboStream()->replace(dom_id($post, 'like'))->view('likes._like', ['post' => $post]),
response()->turboStream()->update(dom_id($post, 'likes_count'), Str::of($post->likes()->count()))
]);
}
}

Example 3: Same Relationship in Two Different Models

This example comes from the serversideup/financial-freedom open-source project. In this project, the same accountable polymorphic relationship exists but in two different models, and the accountable_type is different.

app/Models/Rule.php:

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
 
class Rule extends Model
{
// ...
 
public function accountable()
{
return $this->morphTo();
}
 
// ...
}

Modules/Transaction/app/Models/Transaction.php:

class Transaction extends Model
{
// ...
 
public function accountable()
{
return $this->morphTo();
}
 
// ...
}

When a rule or transaction is created, the account model is passed in, and the class is taken using the get_class() method. Example from storing a rule:

app/Services/Rules/StoreRule.php:

use App\Data\Rules\StoreRuleData;
use App\Models\CashAccount;
use App\Models\CreditCard;
use App\Models\Loan;
use App\Models\Rule;
 
class StoreRule
{
public function execute( StoreRuleData $data )
{
$account = $this->findAccount( $data->account );
 
Rule::create([
'accountable_id' => $account->id,
'accountable_type' => get_class( $account ),
'search_string' => $data->searchString,
'replace_string' => $data->replaceString,
'category_id' => $data->category['id'],
]);
}
 
private function findAccount( $account )
{
switch( $account['type'] ){
case 'cash-account':
return CashAccount::find( $account['id'] );
break;
case 'credit-card':
return CreditCard::find( $account['id'] );
break;
case 'loan':
return Loan::find( $account['id'] );
break;
}
}
}

Example 4: Issues with Comments

This example comes from the nafiesl/free-pmo open-source project. In this project, the relationship is for the comments, which is a very good use case for polymorphic relationships. In this project, comments have a project, an issue, a project, and a job. The relationship is defined as a morphMany.

app/Entities/Projects/Issue.php:

class Issue extends Model
{
// ...
 
public function comments()
{
return $this->morphMany(Comment::class, 'commentable');
}
}

When creating a comment for the issue you do it as with any other relationship.

app/Http/Controllers/Issues/CommentController.php:

use App\Entities\Projects\Comment;
use App\Entities\Projects\Issue;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
 
class CommentController extends Controller
{
public function store(Request $request, Issue $issue)
{
$this->authorize('comment-on', $issue);
 
$newComment = $request->validate([
'body' => 'required|string|max:255',
]);
 
$issue->comments()->create([
'body' => $newComment['body'],
'creator_id' => auth()->id(),
]);
$issue->touch();
 
flash(__('comment.created'), 'success');
 
return back();
}
 
// ...
}

Getting records or eager loading is the same as with any other relationship.

app/Http/Controllers/Projects/IssueController.php:

use App\Entities\Projects\Comment;
use App\Entities\Projects\Issue;
use App\Entities\Projects\IssueStatus;
use App\Entities\Projects\Priority;
use App\Entities\Projects\Project;
use App\Entities\Users\User;
use App\Http\Controllers\Controller;
 
class IssueController extends Controller
{
// ...
 
public function show(Project $project, Issue $issue)
{
$editableComment = null;
$priorities = Priority::toArray();
$statuses = IssueStatus::toArray();
$users = User::pluck('name', 'id');
$comments = $issue->comments()->with('creator')->get();
 
if (request('action') == 'comment-edit' && request('comment_id') != null) {
$editableComment = Comment::find(request('comment_id'));
}
 
return view('projects.issues.show', compact(
'project', 'issue', 'users', 'statuses', 'priorities', 'comments',
'editableComment'
));
}
 
// ...
}

Example 5: Get Model Type using Scopes

This example comes from the tighten/onramp open-source project. In this project, there is a completable polymorphic relationship. A user can complete a module, resource, or skill.

A Model, for example, Module, has a morphMany relationship.

app/Models/Module.php:

use App\Completable;
use Illuminate\Database\Eloquent\Model;
 
class Module extends Model implements Completable
{
// ...
 
public function completions()
{
return $this->morphMany(Completion::class, 'completable');
}
 
// ...
}

A user has multiple completions. However, from the completions relation, scopes are used to get completions by type.

app/Models/User.php:

use Illuminate\Foundation\Auth\User as Authenticatable;
 
class User extends Authenticatable
{
// ...
 
public function completions()
{
return $this->hasMany(Completion::class);
}
 
public function complete(Completable $completable)
{
return $this->completions()->create([
'completable_id' => $completable->getKey(),
'completable_type' => $completable->getMorphClass(),
]);
}
 
public function undoComplete($completable)
{
return $this->completions()->where([
'completable_id' => $completable->getKey(),
'completable_type' => $completable->getMorphClass(),
])->delete();
}
 
public function moduleCompletions()
{
return $this->completions()->modules();
}
 
public function resourceCompletions()
{
return $this->completions()->resources();
}
 
public function skillCompletions()
{
return $this->completions()->skills();
}
 
// ...
}

The scopes are in the Completion Model, which checks the completable_type of the corresponding models class.

app/Models/Completion.php:

use Illuminate\Database\Eloquent\Model;
 
class Completion extends Model
{
// ...
 
public function completable()
{
return $this->morphTo();
}
 
public function scopeModules($query)
{
return $query->where('completable_type', (new Module)->getMorphClass());
}
 
public function scopeResources($query)
{
return $query->where('completable_type', (new Resource)->getMorphClass());
}
 
public function scopeSkills($query)
{
return $query->where('completable_type', (new Skill)->getMorphClass());
}
}

Using methods from the User Model, completions are made in the Controllers.

app/Http/Controllers/ModuleController.php:

use App\Facades\Preferences;
use App\Models\Module;
use Illuminate\View\View;
 
class ModuleController extends Controller
{
public function index(): View
{
return view('modules.index', [
'pageTitle' => __('Modules'),
'standardModules' => auth()->check() && auth()->user()->hasTrack()
? Module::with('resourcesForCurrentSession')->standard()->get()
: Module::standard()->get(),
'bonusModules' => auth()->check() && auth()->user()->hasTrack()
? Module::with('resourcesForCurrentSession')->bonus()->get()
: Module::bonus()->get(),
'userModules' => auth()->check() && auth()->user()->hasTrack()
? auth()->user()->track->modules()->get()->pluck('id')
: collect([]),
'completedModules' => auth()->check()
? auth()->user()->moduleCompletions()->pluck('completable_id')
: collect([]),
'userResourceCompletions' => auth()->check() && auth()->user()->hasTrack()
? auth()->user()->resourceCompletions()->get()->pluck('completable_id')
: collect([]),
]);
}
 
public function show($locale, Module $module, $resourceType): View
{
return view('modules.show', [
'pageTitle' => $module->name,
'module' => $module,
'resources' => $module->resourcesForCurrentSession,
'skills' => $module->skills,
'completedModules' => auth()->check() ? auth()->user()->moduleCompletions()->pluck('completable_id') : collect([]),
'completedResources' => auth()->check() ? auth()->user()->resourceCompletions()->pluck('completable_id') : collect([]),
'completedSkills' => auth()->check() ? auth()->user()->skillCompletions()->pluck('completable_id') : collect([]),
'currentResourceLanguagePreference' => Preferences::get('resource-language'),
'resourceType' => $resourceType,
'previousModule' => $module->getPrevious(),
'nextModule' => $module->getNext(),
]);
}
}

Example 6: One Model Has Many Polymorphic Relationships

This example comes from the academico-sis/academico open-source project. In this project, we have a phoneable polymorphic relationship. A student and a contact can have many phones.

app/Models/Student.php:

use Illuminate\Database\Eloquent\Model;
use Spatie\MediaLibrary\HasMedia;
 
class Student extends Model implements HasMedia
{
// ...
 
public function phone()
{
return $this->morphMany(PhoneNumber::class, 'phoneable');
}
 
// ...
}

app/Models/Contact.php:

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany;
 
class Contact extends Model
{
// ...
 
public function phone(): MorphMany
{
return $this->morphMany(PhoneNumber::class, 'phoneable');
}
 
// ...
}

Example of how many phone numbers are saved for the student.

app/Http/Controllers/Admin/StudentCrudController.php:

use App\Models\PhoneNumber;
use App\Models\Student;
 
class StudentCrudController extends CrudController
{
// ...
 
public function store()
{
// ...
 
$student = Student::create([
'id' => $user->id,
'idnumber' => $request->idnumber,
'address' => $request->address,
'city' => $request->city,
'state' => $request->state,
'country' => $request->country,
'birthdate' => $request->birthdate,
'gender_id' => $request->gender_id,
]);
 
// save phone number
if ($request->phone) {
foreach ($request->phone as $phone_number) {
$phone_number = PhoneNumber::firstOrCreate(['phone_number' => $phone_number['phone_number'], 'phoneable_id' => $student->id, 'phoneable_type' => Student::class]);
 
$student->phone()->save($phone_number);
}
}
 
// ...
}
 
// ...
}

Example 7: Polymorphic with UUID as Primary Key

This example comes from the idanieldrew/redact open-source project. After searching in this project for polymorphic relationships, there are at least four of them: mediaable, commentable, typeable, and statusable.

Defining these relationships in the Model isn't any different from other relationships.

Modules/Users/Models/User.php:

use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\User as Authenticatable;
 
class User extends Authenticatable implements MustVerifyEmail
{
// ...
 
public function types()
{
return $this->morphMany(Token::class, 'typeable');
}
 
public function statuses()
{
return $this->morphMany(Status::class, 'statusable');
}
 
// ...
}

The same is true for creating a record with the polymorphic relationship.

Modules/User/Observers/v1/UserObserver.php:

use Illuminate\Support\Facades\App;
use Module\Token\Services\v1\EmailVerify;
use Module\Token\Services\v1\Verify;
use Module\User\Models\User;
 
class UserObserver
{
// ...
 
public function created(User $user)
{
$user->statuses()->create([
'name' => 'pending',
'reason' => 'needs verification',
]);
 
if (App::environment('local')) {
$verify = new Verify;
$verify->verify(new EmailVerify($user->withoutRelations()));
}
}
}

The difference from other examples is that instead of an ID database column, UUID is used in this project. It's only that the primary column's name is still ID. But, when using UUID as a primary key Laravel have a uuidMorphs() helper to create database columns for polymorphic relationship.

Modules/Status/Database/Migrations/2022_11_07_163620_create_statuses_table.php:

Schema::create('statuses', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('name');
$table->string('reason');
$table->uuidMorphs('statusable');
 
$table->timestamps();
});

Example 8: Reusable with Traits & Only One Type of Address

This example comes from the mostafizurhimself/ecommerce open-source project. In this example, we have an addressable polymorphic relationship where orders can have different addresses, such as different billing and shipping addresses.

The relationship is defined in a trait for re-usability. The address can have delivery, order, and customer.

backend/app/Traits/InteractsWithAddress.php:

use App\Models\Address;
 
trait InteractsWithAddress
{
public function address()
{
return $this->morphMany(Address::class, 'addressable');
}
 
public function setAddress($address, $type)
{
$this->address()->updateOrCreate(
[
'type' => $type,
],
[
'street' => $address['street'],
'city' => $address['city'],
'state' => $address['state'] ?? null,
'zipcode' => $address['zipcode'] ?? null,
'country' => $address['country'],
]
);
}
}

However, while the model can have many addresses, it can only have one type. That's why the setAddress() method uses the updateOrCreate(), and the check is done only on the type column.

Below is an example of address creation for the customer.

backend/app/Traits/CreateCustomer.php:

use App\Models\Customer;
use App\Enums\AddressType;
 
trait CreateCustomer
{
public function createCustomer($request)
{
$customer = Customer::create(array_merge(
$request->only('firstName', 'lastName', 'email', 'phone'),
['password' => bcrypt($request->password)]
));
 
if (!empty($request->billingAddress)) {
$customer->setAddress($request->billingAddress, AddressType::BILLING_ADDRESS());
}
 
if (!empty($request->shippingAddress)) {
$customer->setAddress($request->shippingAddress, AddressType::SHIPPING_ADDRESS());
}
 
return $customer->id;
}
}

Conclusion

From the examples above, I hope you get a complete picture of when to use polymorphics.

This is a great way to have one common database table for repeating relationships, instead of many tables for each Model.