New in Parental v1.5.0: Becoming, Integers, and Eager-Loading

Feature image: New in Parental v1.5.0: Becoming, Integers, and Eager-Loading

We released version v1.5.0 of Parental, our package that adds support for Single Table Inheritance (STI) in Laravel. This new version allows types to become other types and use numeric type columns instead of strings. It also adds new helper methods for eager-loading. Let's dive in!

What is Single Table Inheritance?

Single Table Inheritance is a technique for storing inheritance hierarchies in a single database table.

A while back, we published a deep dive on this topic in Encapsulating Polymorphism by Andrew Morgan, so I will keep the introduction brief, and you can go check out that post if you want to dive deeper.

When using Single Table Inheritance, we usually have a column to flag the type of the record we're storing. When we retrieve the data, we can then instantiate the correct class object based on that. The table also needs to have all the columns needed by all the data types in the hierarchy, even if only a subset of those types use certain columns.

Let's imagine we have a chat application with different types of rooms: Open, Closed, and Direct. The only difference in these would be in behavior, so STI fits perfectly. Here's our database structure:

rooms
  id
  name
  type # STI column
  # ...

The table structure is simple, we only really need the type column so we can apply the STI pattern to it. Here's how we could model that:

use Illuminate\Database\Eloquent\Model;
use Parental\HasChildren;
use Parental\HasParent;

class Room extends Model
{
    use HasChildren;

    protected $guarded = [];

    protected $childTypes = [
        'open' => Open::class,
        'closed' => Closed::class,
        'direct' => Direct::class,
    ];
}

class Closed extends Room
{
    use HasParent;
}

class Direct extends Room
{
    use HasParent;
}

class Open extends Room
{
    use HasParent;
}

So, when we're interacting with the models, the correct instance type will be applied and stored in the database. Here are some examples:

// Stores the type=open in the database and returns an instance of Open class...
$room = Open::create(['name' => 'general']);

// Retrieves an instance of *any* room type and returns
// the class based on the type column in the database...
$room = Room::findOrFail(1); // Open or Closed or Direct

// Applies a scope to the query based on the class being used...
$room = Open::findOrFail(1); // where id=1 and type=open

STI opens up a simplified way of working with polymorphism. Let's explore the new features.

Becoming Other Types

Let's make our example more interesting by adding a behavior to our Open rooms: They should automatically grant access to everyone in the application. We could do that by invoking the behavior from a model event:

class Open extends Room
{
    use HasParent;

    public static function booted(): void
    {
        static::created(function (Open $room) {
            $room->grantAccessToAllUsers();
        });
    }

    public function grantAccessToAllUsers(): void
    {
        // ...
    }
}

Done, right? Hang on: Let's also imagine our application allows us to transition room types: Open rooms can become Closed and vice-versa. When a Closed room becomes Open, it should automatically grant access to all users.

Until now, there was no native way of doing this. To achieve something like this, you'd have to do something like:

// Find the room...
$room = Room::find(1);

// Update the type manually...
$room->update(['type' => 'open']);

// Refetch it so you get back the correct instance type (Open)...
$room = $room->fresh();

// Remember to call the behavior you want from the Open class...
$room->grantAccessToAllUsers();

Here's where the new Become feature contributed by Guillermo Cava comes in handy!

We can now re-arrange that code to simply:

$room = Room::findOrFail(1)->become(Open::class);
$room->save();

The become method takes the class you want to transition the model to, and it returns a new instance of that class, carrying all the attributes from the current model instance. You must either call save() or update($data), passing extra data to ensure the type column change is also persisted in the database.

Now, we can either call the grantAccessToAllUsers method directly, or we could change the hook we're listening to do so automatically:

class Open extends Room
{
    use HasParent;

    public static function booted(): void
    {
        static::saved(function (Open $room) {
            if ($room->wasChanged('type') || $room->wasRecentlyCreated) {
                $room->grantAccessToAllUsers();
            }
        });
    }

    public function grantAccessToAllUsers(): void
    {
        // ...
    }
}

Instead of listening to the created event, we now listen to the saved event, which fires on both inserts and updates. Now we can check if either the type column changed (when Closed rooms become Open) or the Open room was just created. This ensures the behavior is called in both scenarios.

Cool, isn't it?

There's also a new model event you can listen to that fires when the become method is invoked (before the model is persisted), so you can listen to that if you want to:

Open::becoming(function(Open $room) {
    // ...
});

Integer Type Columns

You may now use an integer column instead of a string-based one if you want to. This is useful if you want to shave off some bytes from your database size:

class Room extends Model
{
    use HasChildren;

    protected $childTypes = [
        1 => Open::class,
        2 => Closed::class,
        3 => Direct::class,
    ];
}

Thanks to Gang Wu for the contribution!

Eager-Loading Helpers

I contributed this one to add a bunch of helper methods that allow you to eager-load on the hierarchy. To demo that, let's imagine a different application that has different kinds of Posts: Image posts and Text posts. Image posts have attachments, while Text posts have mentions, for instance. This is the database tables structure:

posts
  id
  creator_id
  title
  type # STI

mentions
  id
  post_id # Post where the user was mentioned
  mentionee_id # User that was mentioned

attachments
  id
  post_id # Post where the attachment lives

Then, our models could be described like:

use Illuminate\Database\Eloquent\Model;
use Parental\HasChildren;
use Parental\HasParent;

class Post extends Model
{
    use HasChildren;

    protected $guarded = [];

    protected $childTypes = [
        'text' => TextPost::class,
        'image' => ImagePost::class,
    ];
}

class TextPost extends Post
{
    use HasParent;

    public function mentions(): HasMany
    {
        return $this->hasMany(Mention::class);
    }
}

class ImagePost extends Post
{
    use HasParent;

    public function attachments(): HasMany
    {
        return $this->hasMany(Attachment::class);
    }
}

Now, let's say you want to list all posts, regardless of the type, and ensure the relationships are properly eager-loaded. Before v1.5.0, there was no native way of doing that. You'd have to manually split the returned collection and eager-load each group of models independently, otherwise you would get an error when trying to eager-load a relationship that does not exist on the model:

$posts = Post::all(); // Collection{[TextPost{id: 1}, ImagePost{id: 2}, ...]}

$posts->load(['mentions', 'attachments']); // Throws an error!

There were ways of doing this yourself, but now it's so simple:

$posts = Post::all();

$posts->loadChildren([
    TextPost::class => ['mentions'],
    ImagePost::class => ['attachments'],
]);

That's it! The collection will automatically be grouped by child type and the relationships will be loaded as a single query for each group!

You may eager-load from a model instance directly, like:

$post = Post::findOrFail(1);

$post->loadChildren([
    TextPost::class => ['mentions'],
    ImagePost::class => ['attachments'],
]);

Then, based on the type of post, the correct relationships will be eager-loaded.

Alternatively, you may eager-load from a Paginator, Eloquent Collection, the Query Builder, and even relationships that use Parental models:

// From an Eloquent Collection...
$post = Post::all();
$post->loadChildren([
    TextPost::class => ['mentions'],
    ImagePost::class => ['attachments'],
]);

// From Paginator...
$post = Post::paginate();
$post->loadChildren([
    TextPost::class => ['mentions'],
    ImagePost::class => ['attachments'],
]);

// From the Query Builder...
$posts = Post::query()->childrenWith([
    TextPost::class => ['mentions'],
    ImagePost::class => ['attachments'],
])->get();

// From a relationship...
$posts = $user->posts()->childrenWith([
    TextPost::class => ['mentions'],
    ImagePost::class => ['attachments'],
])->get();

These helpers were inspired by a community discussion issue and the eager-loading helpers for Polymorphic relationships in Laravel.

Conclusion

That's it for today. We're super excited about these new features in Parental v1.5.0. If you have any questions, feel free to reach out using our contact form or via social media! 👋

Get our latest insights in your inbox:

By submitting this form, you acknowledge our Privacy Notice.

Hey, let’s talk.

By submitting this form, you acknowledge our Privacy Notice.

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.

Thank you!

We appreciate your interest. We will get right back to you.