Ensuring consistent polymorphic relationships with Laravel

October 26, 2020

In a side project that I'm working on, we track the status of licenses for plumbers, electricians, HVAC technicians, along with a whole host of other professions. Each profession has its own license data that we get from various state agencies which means, of course, that every license file has different columns, data formats, levels of specificity, and so on.

We need all of this data combined into a single "licenses" table so that users can search across all licenses at once. Many of our clients employ both plumbers and electricians, or electricians and HVAC technicians. The fact that these licenses come from totally different data sources is an interesting implementation detail, but the users don't care about that, they just want to see:

  • what the status is
  • when it expires
  • what classes need to be taken to renew it

My first thought was to just shoehorn all the disparate data types into a single "licenses" table and be done with it. That would be simple, but we would lose so much source data if we were to do it that way. There are only so many columns that are similar across the various license types, and we like to offer more specific detail on individual licenses whenever we can.

Because we need to keep all the source data, that means that we have to have separate tables for each different license type. But we can't just store the data across a dozen or so tables with no normalized schema, because that would make searching, calculating, and displaying the licenses nearly impossible.

This leads us to our two constraints:

  • Every underlying (source) license record must be kept.
  • Every underlying license needs a normalized representation of itself.

This is the model for what we're describing:

This is a perfect use case for Laravel's Polymorphic Relationships.

If you're not yet aware of polymorphic relations, take a look at this section from the Laravel docs:

A one-to-one polymorphic relation is similar to a simple one-to-one relation; however, the target model can belong to more than one type of model on a single association. For example, a blog Post and a User may share a polymorphic relation to an Image model. Using a one-to-one polymorphic relation allows you to have a single list of unique images that are used for both blog posts and user accounts.

Put simply, polymorphic relationships let you define a relationship that could live in more than one table.

In our case the license model will have an underlying relationship that is the morphed model.

// License.php
 
public function underlying()
{
return $this->morphTo();
}
Code highlighting powered by torchlight.dev (A service I created!)

And each of the underlying models will have a license relationship that references the normalized version:

// Underlying models...
 
public function license()
{
return $this->morphOne(License::class, 'underlying');
}

Now we're able to go from $underlying->license and $license->underlying whenever we need to.

In our case, we have about a dozen different underlying models, and we need to be able to enforce some kind of consistency across all those underlying license types.

Adding a Trait

The first thing that we do to ensure consistency across all those underlying models is add an IsUnderlyingLicense trait to each of them. This becomes the vehicle by which we can enforce more consistency down the road.

The trait allows us to co-locate a bunch of common methods and provide a few helpers. The most obvious thing is the license relationship. Since it's going to be the same for every underlying license, we start there.

trait IsUnderlyingLicense
{
public function license()
{
return $this->morphOne(License::class, 'underlying');
}
}

Now every model has the relationship defined. Easy enough.

One really nice thing that Laravel does with regard to traits is that it gives you an opportunity to "boot" a trait on a model. If you add a boot[TraitName] method, Laravel will call that as the model is booting. This is a perfect spot for you to hook in and provide extra functionality to your trait.

In our case, we're going to do a little bit of MorphMap introspection.

Ensuring the MorphMap

Laravel gives us the ability to register a MorphMap so that the database is not tightly coupled to class names. I prefer to always register a morph map, because then I don't have to worry about refactoring my classes later. I also like to make my types integers instead of strings, but that's just a personal preference.

In our IsUnderlyingLicense trait, we can add some assurance that each underlying license is registered in the morph map. We do this by checking if the getMorphClass is the same as the class name. If it is, we know that we haven't registered it in the map:

trait IsUnderlyingLicense
{
public static function bootIsUnderlyingLicense()
{
if ((new static)->getMorphClass() === static::class) {
throw new Exception('Relation morph not mapped for ' . static::class);
}
}
}

Laravel will call this method automatically when booting the model, and throw an exception if this particular underlying license is not registered in the map. Now I'll never forget to set up the map whenever I add more underlying types in the future.

// AppServiceProvider.php
Relation::morphMap([
1 => LicensePlumber::class,
2 => LicenseHVAC::class,
3 => LicenseElectrician::class,
// etc...
]);

Enforcing a Contract

Speaking of forgetting to do things in the future... adding abstract methods in the IsUnderlyingLicense trait is a great way to enforce a contract on the underlying licenses. For our application, we have one abstract method called spawn that is responsible for generating the normalized license model.

trait IsUnderlyingLicense
{
abstract public function spawn();
}
Code highlighting powered by torchlight.dev (A service I created!)

Anytime I make a new underlying license, my editor is going to let me know that I need to implement the spawn method.

This gives full responsibility to the underlying license as to how the normalized license should be created. It also means that at any point I want I can blow away the licenses table and re-spawn them all from the underlying data.

Of course, we can also provide helper methods to those underlying licenses. We have a spawnSuperLicense method that does some boring work on behalf of the underlying licenses:

trait IsUnderlyingLicense
{
abstract public function spawn();
 
public function spawnSuperLicense($attributes)
{
$attributes = array_merge($attributes, [
'normalized_at' => now(),
]);
 
$license = License::updateOrCreate([
'underlying_type' => $this->getMorphClass(),
'underlying_id' => $this->id,
], $attributes);
 
return $license;
}
}

Iterating Every Underlying Class

Whenever we load new source data into the underlying license tables, we then need to go through every type of underlying license and spawn new normalized licenses.

To make sure we don't miss any in the future, we have added an each helper that finds all those classes for us.

trait IsUnderlyingLicense
{
public static function each($callback)
{
// Find all the files that start with "License"
$models = (new Finder)->in(app_path())->name(['License*'])->files()->depth('< 1');
$models = iterator_to_array($models);
shuffle($models);
 
foreach ($models as $model) {
$class = "App\\" . head(explode('.', $model->getBasename()));
 
// See if the class has the IsUnderlyingLicense trait.
if (!class_has_trait($class, self::class)) {
continue;
}
 
// Callback if so.
$callback($class);
}
}
}

Now, looping through every class becomes as simple as:

// Commands/Normalize.php
 
public function handle()
{
IsUnderlyingLicense::each(function($class) {
$class::needsNormalizing()->get()->each->spawn();
});
}

It's nice to have that as a convenience, but more importantly we never have to remember to come back and update the list of underlying licenses.

Sharing Scopes

The eagle-eyed reader will note that there must be a needsNormalizing scope in every underlying license for the code above to work properly. The scope lives in that trait as well:

trait IsUnderlyingLicense
{
public function scopeNeedsNormalizing($query)
{
$table = $query->toBase()->from;
 
$query
// There is no super license at all.
->whereDoesntHave('license')
// Or the underlying license has a was updated more
// recently than the normalized license.
->orWhereHas('license', function ($sub) use ($table) {
$sub->whereColumn("$table.updated_at", '>', 'licenses.normalized_at');
});
}
}

(Note: that's an abbreviated version of the scope. I'll do a write-up soon of all that goes into normalizing abnormal data. It's... a lot.)

Ensuring Columns via Tests

There are a few common columns that we want to store at the underlying license level as opposed to the normalized level, so we need a way to enforce that those columns exist.

I've found that the easiest way to do this is by adding a list of columns you need to ensure to the trait, and then using a test to verify they are there.

First we'll add the list of columns to the trait:

trait IsUnderlyingLicense
{
public static $ensureColumnsExist = [
'data_fetched_at'
];
}

and then we'll create a relatively simple test:

class AllUnderlyingLicensesHaveColumnsTest extends TestCase
{
use RefreshDatabase;
 
/** @test */
public function they_have_the_columns()
{
IsUnderlyingLicense::each(function ($class) {
$table = (new $class)->getTable();
$columns = collect(DB::select("SHOW COLUMNS from $table;"))->keyBy('Field');
 
foreach (IsUnderlyingLicense::$ensureColumnsExist as $column) {
$this->assertTrue(
$columns->has($column),
"$table does not have the $column column."
);
}
});
}
}

We loop through every underlying license and get all of the columns as they currently exist in the database and then compare them to the columns listed in the trait. If a particular underlying license doesn't have one of the columns, then the test will fail.

Again, nothing to remember!

The Final Word

Everything we've done here is to give ourselves confidence that we won't muck anything up in the future. Often times in scenarios like these, a lot of knowledge is stored in the head of the person or people that originally made the structure.

Instead, try to make as much of that explicit as possible, as a kindness to your future self and coworkers.

Me

Thanks for reading! My name is Aaron and I write, make videos , and generally try really hard .

If you ever have any questions or want to chat, I'm always on Twitter.

You can find me on YouTube on my personal channel or my behind the scenes channel.

If you love podcasts, I got you covered. You can listen to me on Mostly Technical .