Handling model inheritance in Laravel’s Eloquent ORM without third-party libraries (is a bad idea)
Published
Written by
Chun Fei Lung
It’s technically possible to make Eloquent models inherit from each other, but I probably wouldn’t recommend it.
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.
We want our application to create Animal objects and store them in its
database.
This should be an easy task for Eloquent, Laravel’s , 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:
The Animal entity class contains all fields and methods that we need for both
types of animals. I tried to make its methods and 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.
The migration for the Animal entity is simple. We only have to create one
table, which we’ll name animals:
I wrote an AnimalTest with a few unit tests to illustrate what it’s like to
work with this Animal class:
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:
(Don’t use) single-table inheritance
We’d like to rely less on if statements and , and more
on type annotations that clearly communicate to programmers and static analysis
tools what we’re dealing with:
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:
We’ll move Cat-specific attributes and methods to the Cat class, which extends
from Animal:
The Dog class looks a lot like Cat, but its internal type is Type::DOG
and it’s capable of barking and rolling over:
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 . 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:
That is, of course, not to say that nothing has changed. The updated version of
the AnimalTest shows that things have indeed changed:
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:
We’ll also define the inverse of this new relationship on the Animal 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:
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. , 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:
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:
The Dog class is virtually identical to the Cat class, except for a few
small additions (which I’ll get back to later):
As is already implied by the name “multi-table inheritance”, the migration
creates three tables; one for each class:
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:
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.