Back

Eloquent Single Table Inheritance


ShawnMcCool posted 3 years ago

What is Single Table Inheritance?

I saw a post by Kapil Verma where he shared his STI base class. I cleaned it up a bit and it seems to be running solidly. There may be omissions. But, I'll update it as I use it.

abstract class SingleTableInheritanceEntity extends Eloquent
{
    // the field that stores the subclass
    protected $subclassField = null;
    // must be overridden and set to true in subclasses
    protected $isSubclass = false;

    public function isSubclass()
    {
        return $this->isSubclass;
    }

    // if no subclass is defined, function as normal
    public function mapData(array $attributes)
    {
        if ( ! $this->subclassField) {
            return $this->newInstance();
        }

        return new $attributes[$this->subclassField];
    }

    // instead of using $this->newInstance(), call
    // newInstance() on the object from mapData
    public function newFromBuilder($attributes = array())
    {
        $instance = $this->mapData((array) $attributes)->newInstance(array(), true);
        $instance->setRawAttributes((array) $attributes, true);
        return $instance;
    }

    public function newQuery($excludeDeleted = true)
    {
        // If using Laravel 4.0.x then use the following commented version of this command
        // $builder = new Builder($this->newBaseQueryBuilder());
        // newEloquentBuilder() was added in 4.1
        $builder = $this->newEloquentBuilder($this->newBaseQueryBuilder());

        // Once we have the query builders, we will set the model instances so the
        // builder can easily access any information it may need from the model
        // while it is constructing and executing various queries against it.
        $builder->setModel($this)->with($this->with);

        if ($excludeDeleted && $this->softDelete) {
            $builder->whereNull($this->getQualifiedDeletedAtColumn());
        }

        if ($this->subclassField && $this->isSubclass()) {
            $builder->where($this->subclassField, '=', get_class($this));
        }

        return $builder;
    }

    // ensure that the subclass field is assigned on save
    public function save(array $options = array())
    {
        if ($this->subclassField) {
            $this->attributes[$this->subclassField] = get_class($this);
        }
        return parent::save($options);
    }
}

Then, let's imagine that you have a notifications database table. It has a few fields, id, user_id, class, subject_type, subject_id. Subject is a polymorphic relation to the item that the notification is for. Then, we have a class ForumTag so that if a user is tagged no the forum, it creates this sort of notification.

class Notification extends SingleTableInheritanceEntity
{
    protected $table = 'notifications';
    protected $subclassField = 'class';

    public function setSubject(Model $object)
    {
        $this->attributes['subject_type'] = get_class($object);
        $this->attributes['subject_id'] = $object->getKey();
    }
}

class ForumTag extends Notification
{
    protected $isSubclass = true;
}

creating a notification

$notification = new ForumTag;
$notification->setSubject($forumThread);
$notification->save();

retrieve all of a user's notifications

The resulting collection here will include ForumTag classes.

$notifications = Notification::where('user_id', '=', $userId)->get();

retrieve specifically only ForumTag notificatinos

$notifications = ForumTag::where('user_id', '=', $userId)->get();

Warning

Single Table Inheritance should only be used when different types have the exact same fields, but need different behavior. You never want to end up adding new nullable fields just for the needs of a specific type.

More Information on When and Why to use Single Table Inheritance

zenry replied 3 years ago

very interesting, I'm going to implement it as well

zenry replied 3 years ago

I've changed something

public function save(array $options = array())
{
    if ($this->subclassField) {
        $this->attributes[$this->subclassField] = get_class($this);
    }

    return parent::save($options); // return boolean
}

In my project I check if the model is saved buy doing something like

$image = $this->image->newInstance();
$image->name = 'test image';

return $image->save() ? $image : null;

By returning parent::save($options) I can check if the save went ok

ShawnMcCool replied 3 years ago

@zenry

I agree that the parent::save() should be returned. This is something that I've since implemented as well. I've updated the original post to reflect the change.

jasonlewis replied 3 years ago

This is a really neat idea. With a side project I'm working on I might look at using this approach. I'll have a whole bunch of incidents, such as fire, car accident, flood, etc. Single Table Inheritance should make this a whole lot easier.

crhayes replied 3 years ago

I'm looking into using STI for my users table. I'm using Sentry and have 9 different user groups, and my User model is becoming obscene with all logic required for different types of users. This way I can have a base User class, and then either have a class for each user group, or a few different classes to represent similar user groups.

Regarding adding nullable columns: I completely agree. Although, if your data starts to change over time (i.e. in the case of different user groups, maybe new fields are required only for a subset of the groups) you can split it up into another table (i.e. admin_user_profile, member_user_profile).

patrickcarlohickman replied 3 years ago

Seems to be working well. I just wanted to share one change I made:

public function newQuery($excludeDeleted = true)
{
    $builder = parent::newQuery($excludeDeleted);

    // if this is a subclass, add the subclass field to the query
    if ($this->subclassField && $this->isSubclass()) {
        $builder->where($this->subclassField, '=', get_class($this));
    }

    return $builder;
}

It looked like the only change to the newQuery function was adding in the where clause for the subclassField, so I just have it call the parent method, and then customize the returned builder and return it. This also helps remove the L4.0/L4.1 check mentioned in the comments.

Please let me know if anyone sees any issues with this.

ryanabrams replied 2 years ago

Sorry to resurrect an old thread; I wanted to mention that I found this very helpful, but in t removed the whole $isSubclass approach, and replaced the function with the following:

public function isSubclass()
{
   return (get_parent_class($this) != self::class && is_subclass_of($this,self::class));
}

In my testing so far, this seems to work well regardless of what I name the abstract class, and doesn't require a specific variable in each of my subclass models.

That said, let me know if there are issues with this approach that I may not have encountered yet.

tbruckmaier replied 2 years ago

ryanabrams change works just fine, in PHP <= 5.4 you have to use CLASS instead of self::class though.

    public function isSubclass()
    {
        return (
            get_parent_class($this) != __CLASS__
            && is_subclass_of($this, __CLASS__)
        );
    }

Sign in to participate in this thread!



We'd like to thank these amazing companies for supporting us