In this article

“Heap, Heap, Array!”

Handling model inheritance in Laravel’s Eloquent ORM without third-party libraries (is a bad idea)

It’s technically possible to make Eloquent models inherit from each other, but I probably wouldn’t recommend it.

It’s like forcing a square peg into a round hole
A square peg (Laravel) is forced into a round hole (best practices)

It’s like forcing a square peg into a round hole

In this post I’m going to pretend that we’re building a Laravel app. It doesn’t really matter what the app does. What’s important is that for some undisclosed and very unimportant reason this app’s domain involves two types of animals: cats and dogs.

Almost every introductory textbook or blog post on object-oriented programming will tell you that you should model this domain using inheritance and polymorphism, so that’s exactly what we’ll do here.

The figure below shows the class diagram for our domain. As you can see, it’s pretty simple:

  • All animals have names, can greet others and sit;
  • Additionally, dogs can be asked to roll over;
  • Cats must have a servant, while dogs must have an owner.
Class diagram in which Cat and Dog inherit from Animal; Animal has a name and greet and sit methods, Cat has a servant, and Dog has an owner and roll-over method

We want our application to create Animal objects and store them in its database.

This should be an easy task for Eloquent, Laravel’s object-relational mapper (ORM), but curiously enough its documentation doesn’t mention inheritance, subclasses or polymorphic hierarchies even once. So how do we go about implementing this model using Eloquent?

Without inheritance

There are two common ways to implement inheritance in Laravel, but before we go into these approaches let me first show why we would want to use inheritance.

The snippets below show a first attempt to implement the domain model without inheritance. This means we have an Animal class which we’ll use for everything.

We can distinguish between different types of Animals using an Animal\Type enum:

enum Type: string
{
case CAT = 'cat';
case DOG = 'dog';
}

The Animal entity class contains all fields and methods that we need for both types of animals. I tried to make its methods and attributes a little bit more sane by making them throw exceptions when someone attempts to access them on the wrong type of Animal. It’s ugly, but at least it (sort of) works.

/**
* @property string name
* @property string type
* @property string owner
* @property string servant
*/
class Animal extends Model
{
protected $fillable = [
'name',
'type',
'servant',
'owner',
];
public function greet(): string
{
if ($this->type === Type::CAT) {
return 'Meow!' . PHP_EOL;
}
if ($this->type === Type::DOG) {
return 'Woof!' . PHP_EOL;
}
throw new \OutOfBoundsException();
}
public function sit(): string
{
return '*sits*' . PHP_EOL;
}
public function rollOver(): string
{
if ($this->type === Type::DOG) {
return '*rolls over*' . PHP_EOL;
}
throw new \BadMethodCallException();
}
public function servant(): Attribute
{
if ($this->type !== Type::CAT) {
throw new \BadMethodCallException();
}
return Attribute::make();
}
public function owner(): Attribute
{
if ($this->type !== Type::DOG) {
throw new \BadMethodCallException();
}
return Attribute::make();
}
}

The migration for the Animal entity is simple. We only have to create one table, which we’ll name animals:

return new class extends Migration
{
public function up(): void
{
Schema::create('animals', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->string('type');
$table->string('servant')->nullable();
$table->string('owner')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::drop('animals');
}
};

I wrote an AnimalTest with a few unit tests to illustrate what it’s like to work with this Animal class:

class AnimalTest extends TestCase
{
use RefreshDatabase;
public function test_cat_happy_path(): void
{
// > Arrange
/** @var Animal $cat */
$cat = Animal::create([
'name' => 'Cathay Purrcific',
'type' => Animal\Type::CAT,
'servant' => 'Swire',
]);
// > Act
$result = '';
$result .= $cat->greet();
$result .= $cat->sit();
// > Assert
self::assertEquals(
'Meow!' . PHP_EOL .
'*sits*' . PHP_EOL,
$result
);
}
public function test_dog_happy_path(): void
{
// > Arrange
/** @var Animal $dog */
$dog = Animal::create([
'name' => 'Barky McBarkface',
'type' => Animal\Type::DOG,
'owner' => 'Barkeeper',
]);
// > Act
$result = '';
$result .= $dog->greet();
$result .= $dog->sit();
$result .= $dog->rollOver();
// > Assert
self::assertEquals(
'Woof!' . PHP_EOL .
'*sits*' . PHP_EOL .
'*rolls over*' . PHP_EOL,
$result
);
}
public function test_dogs_cannot_have_servants(): void
{
// > Arrange (expectations)
$this->expectException(\BadMethodCallException::class);
// > Arrange (data)
/** @var Animal $dog */
$dog = Animal::create([
'name' => 'Buddy',
'type' => Animal\Type::DOG,
'owner' => 'Some dude',
]);
// > Act
$dog->servant = 'This should not work';
}
public function test_dogs_cannot_be_created_with_servant(): void
{
// > Arrange (expectations)
$this->expectException(\BadMethodCallException::class);
// > Act
/** @var Animal $dog */
Animal::create([
'name' => 'Buddy',
'type' => Animal\Type::DOG,
'servant' => 'This should not work',
]);
}
public function test_dogs_cannot_be_created_without_owner(): void
{
// > Act
/** @var Animal $dog */
Animal::create([
'name' => 'Buddy',
'type' => Animal\Type::DOG,
]);
// What now?
$this->markTestSkipped('This actually works right now…');
}
}

Most things works as expected, but there are a few things that make me sad.

All fields in our class diagram are required, but this is not actually the case in our implementation. Only the name and type fields are required. Unless we add some custom validation, Laravel happily lets us create cats without servants and dogs without owners.

Moreover, as far as static analysers (like those in your IDE) are concerned, all Animals are equal. They don’t understand that cats can only have servants and that only dogs can roll over, and thus are unable to provide suggestions or point out mistakes. Instead, you’ll be forced to use if statements everywhere and simply hope for the best:

function doSomething(Animal $animal): void
{
if ($animal->type !== Animal\Type::DOG) {
return;
}
$animal->rollOver();
}

(Don’t use) single-table inheritance

We’d like to rely less on if statements and our ability to write code that is free from mistakes, and more on type annotations that clearly communicate to programmers and static analysis tools what we’re dealing with:

function doSomething(Dog $dog): void
{
$dog->rollOver();
}

Doctrine, Eloquent’s major competitor, provides support for several types of inheritance mapping, of which single table inheritance is probably the easiest to understand. In our case, this would mean that we have a single table animals that stores the data for all instances of Animal, including that of its child classes Cat and Dog.

Let’s see if we can try to get something like this working using Eloquent. We’ll start with a new version of the Animal class. This time we make it abstract so that it cannot be instantiated directly. We also get rid of everything that’s not shared by all animals:

/**
* @property string name
*/
abstract class Animal extends Model
{
public function sit(): string
{
return '*sits*' . PHP_EOL;
}
abstract public function greet(): string;
}

We’ll move Cat-specific attributes and methods to the Cat class, which extends from Animal:

/**
* @property string servant
*/
class Cat extends Animal
{
protected $table = 'animals';
protected $fillable = [
'servant',
// From parent
'name',
];
protected static function boot()
{
parent::boot();
static::addGlobalScope('species', function (Builder $builder) {
$builder->where('type', Type::CAT);
});
static::creating(function ($animal) {
$animal->type = Type::CAT;
});
}
public function greet(): string
{
return 'Meow!' . PHP_EOL;
}
}

The Dog class looks a lot like Cat, but its internal type is Type::DOG and it’s capable of barking and rolling over:

/**
* @property string owner
*/
class Dog extends Animal
{
protected $table = 'animals';
protected $fillable = [
'owner',
// From parent
'name',
];
protected static function boot()
{
parent::boot();
static::addGlobalScope('species', function (Builder $builder) {
$builder->where('type', Type::DOG);
});
static::creating(function ($animal) {
$animal->type = Type::DOG;
});
}
public function greet(): string
{
return 'Woof!' . PHP_EOL;
}
public function rollOver(): string
{
return '*rolls over*' . PHP_EOL;
}
}

The @property PHPDoc annotation on these classes tell IDEs that only cats can have servants and only dogs can have owners. The greet() method has also become a lot simpler now that the logic for cats and dogs no longer resides in the same file. These seem like clear improvements!

However, we have also done some things that might come back and bite us in the ass later:

  • By default, Laravel determines the table name of an entity by pluralising its name and converting it to snake case. But that’s not what we want: cats and dogs are animals, and thus should all be stored in the animals table.

  • Each subclass has its own protected $fillable. In this case, we’d like to array_merge() the fillable attributes of each subclass with those of its parent class. Sadly this isn’t possible, so we’ll either have to use a non-standard method to tell Laravel which attributes are “fillable” or manually repeat ourselves across all Animal subclasses (which we’ve done here).

  • Eloquent doesn’t understand class hierarchies. This is why we have to tell it explicitly how the Cat and Dog classes should behave, by providing some definitions in the static boot() function. For now I have only told it that Cats and Dogs are distinguished by the value of their type attribute (which has now become internal) and that its value should be used when fetching entities via the query builder.

Despite these changes, the migration code that we need for Animal, Cat and Dog is identical to the migration that we used previously:

return new class extends Migration
{
public function up(): void
{
Schema::create('animals', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->string('type');
$table->string('owner')->nullable();
$table->string('servant')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::drop('animals');
}
};

That is, of course, not to say that nothing has changed. The updated version of the AnimalTest shows that things have indeed changed:

class AnimalTest extends TestCase
{
use RefreshDatabase;
public function test_cat_happy_path(): void
{
// > Arrange
/** @var Cat $cat */
$cat = Cat::create([
'name' => 'Cathay Purrcific',
'servant' => 'Swire',
]);
// > Act
$result = '';
$result .= $cat->greet();
$result .= $cat->sit();
// > Assert
self::assertEquals(
'Meow!' . PHP_EOL .
'*sits*' . PHP_EOL,
$result
);
self::assertEquals('Cathay Purrcific', $cat->name);
}
public function test_dog_happy_path(): void
{
// > Arrange
/** @var Dog $dog */
$dog = Dog::create([
'name' => 'Barky McBarkface',
'owner' => 'Barkeeper',
]);
// > Act
$result = '';
$result .= $dog->greet();
$result .= $dog->sit();
$result .= $dog->rollOver();
// > Assert
self::assertEquals(
'Woof!' . PHP_EOL .
'*sits*' . PHP_EOL .
'*rolls over*' . PHP_EOL,
$result
);
self::assertEquals('Barky McBarkface', $dog->name);
}
public function test_dogs_cannot_have_servants(): void
{
// > Arrange (data)
/** @var Dog $dog */
$dog = Dog::create([
'name' => 'Buddy',
'owner' => 'Some dude',
]);
// > Act
$dog->servant = 'This should not work';
$dog->save();
// What now?
$this->markTestSkipped('This actually works right now…');
}
public function test_dogs_cannot_be_created_with_servant(): void
{
// > Act
/** @var Dog $dog */
Dog::create([
'name' => 'Buddy',
'servant' => 'This should not work',
]);
// What now?
$this->markTestSkipped('This actually works right now…');
}
public function test_dogs_cannot_be_created_without_owner(): void
{
// > Act
/** @var Dog $dog */
Dog::create(['name' => 'Buddy']);
// What now?
$this->markTestSkipped('This actually works right now…');
}
}

The code has become a bit clearer, as it now involves actual Cats and Dogs rather than generic Animals with a type. However, not only is it still possible to create dogs without owners, we can now also create dogs that have servants and cats that have owners?!

Another major problem is that relationships with Animals don’t work. Imagine we want to keep track of the type of food that each animal eats. Normally, we’d create a new Food entity that might look a bit like this:

class Food extends Model
{
protected $fillable = [
'name',
];
public function isEatenBy(): HasMany
{
return $this->hasMany(Animal::class);
}
}

We’ll also define the inverse of this new relationship on the Animal class:

public function eats(): BelongsTo
{
return $this->belongsTo(Food::class);
}

But this won’t work the way we want it to, because when we try to retrieve the animals that eat a particular type of food, PHP will tell us that it can’t instantiate Animal classes because they’re abstract:

public function test_dog_eats_shoes(): void
{
// > Arrange
/** @var Dog $dog */
$dog = Dog::create([
'name' => 'Barky McBarkface',
'owner' => 'Barkeeper',
]);
// > Act
/** @var Food $food */
$food = Food::create(['name' => 'shoes']);
$dog->eats()->associate($food);
// > Assert
// This doesn’t work!
self::assertEquals('Barky McBarkface', $food->isEatenBy->name);
}

Although I’m sure that all these issues can be fixed by overriding default Eloquent functionality, it’s rarely a good idea to fight the framework. I guess we’ll have to look for a more Laravel-esque approach…

Multi-table inheritance

Eloquent provides support for polymorphic relationships, in which an entity can be related to multiple other types of entities which can be completely unrelated to each other. This is a feature you don’t see in many other ORMs, but it appears to be the most straightforward way to implement something that looks a bit like multi-table inheritance.

Let’s start with a minimal Animal entity class again:

/**
* @property string name
* @property MorphTo species
*/
class Animal extends Model
{
protected $fillable = [
'name',
];
public function species(): MorphTo
{
return $this->morphTo('species');
}
public function sit(): string
{
return '*sits*' . PHP_EOL;
}
}

This Animal class has a MorphTo relationship that’s named species. We’ll refer to this same relationship in the Cat and Dog classes, which extend from Animal. For instance, this is what the Cat class looks like:

/**
* @property string servant
* @property MorphOne animal
*/
class Cat extends Animal implements Greetable
{
protected $fillable = [
'servant',
];
public function greet(): string
{
return 'Meow!' . PHP_EOL;
}
public function animal(): MorphOne
{
return $this->morphOne(Animal::class, 'species');
}
}

The Dog class is virtually identical to the Cat class, except for a few small additions (which I’ll get back to later):

/**
* @property string owner
* @property MorphOne animal
* @property string name
*/
class Dog extends Animal implements Greetable
{
protected $fillable = [
'owner',
];
public function greet(): string
{
return 'Woof!' . PHP_EOL;
}
public function animal(): MorphOne
{
return $this->morphOne(Animal::class, 'species');
}
public function rollOver(): string
{
return '*rolls over*' . PHP_EOL;
}
public function name(): Attribute
{
return Attribute::make(
get: fn (?string $value) => $this->animal->name,
set: fn (?string $value) => $this->animal->name,
);
}
}

As is already implied by the name “multi-table inheritance”, the migration creates three tables; one for each class:

return new class extends Migration
{
public function up(): void
{
Schema::create('animals', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->morphs('species');
$table->timestamps();
});
Schema::create('cats', function (Blueprint $table) {
$table->increments('id');
$table->string('servant');
$table->timestamps();
});
Schema::create('dogs', function (Blueprint $table) {
$table->increments('id');
$table->string('owner');
$table->timestamps();
});
}
public function down(): void
{
Schema::drop('dogs');
Schema::drop('cats');
Schema::drop('animals');
}
};

The morphs() method creates two columns, species_type and species_id, which are used to store the fully-qualified class name (FQCN) and the id of the corresponding entity respectively.

Naturally I’ve also updated the AnimalTest, which doesn’t contain any failing tests this time:

class AnimalTest extends TestCase
{
use RefreshDatabase;
public function test_cat_happy_path(): void
{
// > Arrange
/** @var Animal\Cat $cat */
$cat = Animal\Cat::create(['servant' => 'Swire']);
$cat->animal()->create(['name' => 'Cathay Purrcific']);
// > Act
$result = '';
$result .= $cat->greet();
$result .= $cat->sit();
// > Assert
self::assertEquals(
'Meow!' . PHP_EOL .
'*sits*' . PHP_EOL,
$result
);
self::assertEquals('Cathay Purrcific', $cat->animal->name);
}
public function test_dog_happy_path(): void
{
// > Arrange
/** @var Animal\Dog $dog */
$dog = Animal\Dog::create(['owner' => 'Barkeeper']);
$dog->animal()->create(['name' => 'Barky McBarkface']);
// > Act
$result = '';
$result .= $dog->greet();
$result .= $dog->sit();
$result .= $dog->rollOver();
// > Assert
self::assertEquals(
'Woof!' . PHP_EOL .
'*sits*' . PHP_EOL .
'*rolls over*' . PHP_EOL,
$result
);
self::assertEquals('Barky McBarkface', $dog->name);
}
public function test_dogs_cannot_have_servants(): void
{
// > Arrange (expectations)
$this->expectException(QueryException::class);
// > Arrange (data)
/** @var Animal\Dog $dog */
$dog = Animal\Dog::create(['owner' => '']);
// > Act
$dog->servant = 'This should not work';
$dog->save();
}
public function test_dogs_cannot_be_created_with_servant(): void
{
// > Arrange (data)
Animal\Dog::create([
'owner' => '',
'servant' => 'Barkeeper',
]);
// > Assert
self::assertNull($dog->servant);
}
public function test_dogs_cannot_be_created_without_owner(): void
{
// > Arrange (expectations)
$this->expectException(QueryException::class);
// > Arrange (data)
Animal\Dog::create();
}
}

This approach, although better than the two before it, is still far from perfect. My biggest gripe about this implementation is that the model is all wrong.

Yes, the Cat and Dog classes inherit from Animal in the traditional sense, which means that cats and dogs “are” animals. However, the Eloquent relationship suggests that Cats and Dogs “have” an Animal. test_cat_happy_path() clearly shows how awkward it is to work with these classes:

  • Cat::create() doesn’t give us a “complete” object. We also need to create the corresponding Animal entity by calling $cat->animal()->create(), which will likely end up getting a different id than the Cat.

  • We cannot retrieve the name of a Cat directly by accessing the name attribute – we have to do it through the species relationship: $cat->animal->name.

These issues can be solved by patching the boot() function in each Model and possibly overriding some other default Eloquent behaviour (like how Dogs name attribute gets and sets its value).

Is this approach better than sprinkling if statements everywhere? I personally think it’s worth it, but only if you can completely hide the implementation of these classes from the outside world.

This article appears in