My direct exposure to the Laravel eco-system in use by all on this forum is new and very limited, so please forgive my ignorance.
The Situation
I’ve designed a few before though, so right now I’m busy defining a suitable backend for a website concept I’m developing. Don’t worry, I’ll leave the actual coding to experts, but it’s an intricate yet potentially powerful data model so I undertook to build all my ideas about what it can do right into the heart of it – the data model.
The model features several many-to-many relationships, some controlled by meta-data and others using more standard pivot table. All went well until I developed the need to morph one of the central tables. That meant I needed a self-referencing, many-to-many morphing relationship.
Searching for a Solution
I could find very few references to such possible examples or discussions beyond an unanswered request from 2014 and a wink-faced remark in answer to another post by some level 50 contributor suggesting the OP was looking for some holy grail.
I spent many cups of coffee and a lot of clunky code on making some progress towards the results I wanted. Eventually I saw some light. I was able to simplify it right down to a single trait containing two single-line relationships, and 2 small functions to be used in each of the morphing models, and a custom model with 2 simple relationships for the pivoting.
Sharing my Solution
I’d like to share and explain my implementation for three reasons:
- It’s the right thing to do,
- It could help somebody solve a complex problem in a simple way and
- I’d love insights on how my solution will break using live/large data.
Morphing ICE
In my application, I have three persona types Individual, Collective and External. By -able convention I should have (and at one stage did) call the morph name ‘personable’ or ‘persona’. It works but did me no favours in keeping my ducks in a row when defining the self-referencing relationship. My approach is to pick two unambiguous terms that fit my use case. I was dealing with personas (either Individual, Collective or External) and how they are organised into groups with members. That helps me keep it straight when defining (and debugging) the relationships and is priceless when it comes to using it. As far as I’m concerned, that’s the missing element in the current Laravel/Eloquent framework which is built around a single new term like likeable.
Even when defining single-model (i.e. not morphed) models, it helps to remember that the only place the two sets of names meet, is in the data of the pivot model. Everywhere else you are essentially dealing with two one-to-many relationships. One for "group" (or whatever term you choose) and similarly the other for "member". There’s never a need to compare values of the group-side with values from the member-side. They are separate and independent except that for every combination that exist, there would be a row in the pivot table where both sets of values are stored.
"SelfMorphship"
The key to the solution is a trait I called SelfMorphship (combining self-referencing, morphing and Membership into one name)
SelfMorphship.php
namespace App;
trait SelfMorphship
{
public function addMember($member)
{
return Membership::firstOrCreate([
'group_type' => $this->getMorphClass(),
'group_id' => $this->id,
'member_type' => $member->getMorphClass(),
'member_id' => $member->id
]);
}
public function addToGroup($group)
{
return Membership::firstOrCreate([
'group_type' => $group->getMorphClass(),
'group_id' => $group->id,
'member_type' => $this->getMorphClass(),
'member_id' => $this->id
]);
}
public function groups()
{
return $this->morphMany(Membership::class, 'member')->with('group');
}
public function members()
{
return $this->morphMany(Membership::class, 'group')->with('member');
}
}
And Membership model it refers to.
Membership.php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Membership extends Model
{
protected $fillable = [
'group_type', 'group_id',
'member_type', 'member_id'
];
protected $casts = [
'id' => 'integer',
'group_id' => 'integer',
'member_id' => 'integer'
];
public function member()
{ //MorphTo morphTo($name, $type, $id, $ownerKey)
return $this->morphTo('member', 'member_type', 'member_id', 'id');
}
public function group()
{ //MorphTo morphTo($name, $type, $id, $ownerKey)
return $this->morphTo('group', 'group_type', 'group_id', 'id');
}
}
You’ll notice above in the member and group relationship functions, I’ve written out the values that would normally be guessed by the platform if all works well. By rights, the morphTo() would be called without parameters and take it’s cues from the function name, but I wanted to show how those end up being the field names involved and how the group-side and the member-side never refer to each other as explained above.
From …_create_memberships_table.php
Schema::create('memberships', function (Blueprint $table) {
$table->primary(['group_type', 'group_id',
'member_type', 'member_id'])
->index('unique_memberships');// shorter name
$table->char('group_type');
$table->unsignedInteger('group_id');
$table->string('member_type');
$table->unsignedInteger('member_id');
$table->index(['group_type', 'group_id' ]);
$table->index(['member_type','member_id']);
$table->timestamps();
Using SelfMorphship
I use three cool’ Models - Individual, Collective and External (ICE, thus cool) but apart from their specialised contents like Individual being the only one foreignId’d to the users table, they become relatable in a many-to-many group and member relationship with use SelfMorphship; like such:
From Individual.php, Collective.php and External.php
use Illuminate\Database\Eloquent\Model;
class Individual extends Model
{
use SelfMorphship;
//
}
class Collective extends Model
{
use SelfMorphship;
//
}
class External extends Model
{
use SelfMorphship;
//
}
Notes
- I use a Custom morphMap to reduce the long names for classes and keep them out of my database, so my actual ‘-_type’ fields are typed as char(4).
- Using this code unchanged could result in every set of models you’d like to relate to itself in a group and member arrangement would storing their pivot data in the same table. It might be what you prefer, or it could lead to unexpected performance issues where your small datasets are being impacted by the size of your big datasets. It is easily corrected by cloning the trait which refers to the Membership class directly. If I knew how, I would want to make the pivot entity a parameter to the trait, but this is sufficient for my purposes right now.
Points to ponder
- There exists a notion in all I’ve read about Eloquent on this and other forums like Stack Exchange, that when morphing relationships you ‘need’ to define a morphMany() relationship for each morphed model. I nearly gave up because of that, as it would have absolutely ruined the morph concept for me. The whole point for me was that I can refer to any and all types in the same way when I want to and separately when I want to.
- The contains(‘id’, $collection) tests for these relationships work, but requires that the programmer using it remembers that the actual collection is a pivot entity with the morphed models eagerly loaded into those. When referring to the actual member or group model, you need to prefix their values. In text it would be ‘group.id’ or ‘member.moniker’ and in model references it would be $members->group->id and $groups->member->moniker. The real issue and reason I mention it here is that at best these tests fails silently and at worst they yield incorrect results as, by my implementation, ‘id’ refers to the id of the pivot model if you have one.