A Single Table Inheritance Trait for Eloquent/Laravel

Overview

Single Table Inheritance

Build Status Latest Stable Version Total Downloads Latest Unstable Version License

Single Table Inheritance is a trait for Laravel 5.8+ Eloquent models that allows multiple models to be stored in the same database table. We support a few key features

  • Implemented as a Trait so that it plays nice with others, such as Laravel's SoftDeletingTrait or the excellent Validating, without requiring a complicated mess of Eloquent Model subclasses.
  • Allow arbitrary class hierarchies not just two-level parent-child relationships.
  • Customizable database column name that is used to store the model type.
  • Customizable string for the model type value stored in the database. (As opposed to forcing the use of the fully qualified model class name.)
  • Allow database rows that don't map to known model types. They will never be returned in queries.

Installation

Simply add the package to your composer.json file and run composer update.

"nanigans/single-table-inheritance": "~1.0"

Or go to your project directory where the composer.json file is located and type:

composer require "nanigans/single-table-inheritance:~1.0"

Overview

Getting started with the Single Table Inheritance Trait is simple. Add the constraint and add a few properties to your models. A complete example of a Vehicle super class with two subclasses Truck and Car is given by

use Nanigans\SingleTableInheritance\SingleTableInheritanceTrait;

class Vehicle extends Model
{
  use SingleTableInheritanceTrait;

  protected $table = "vehicles";

  protected static $singleTableTypeField = 'type';

  protected static $singleTableSubclasses = [Car::class, Truck::class];
}

class Car extends Vehicle
{
  protected static $singleTableType = 'car';
}

class Truck extends Vehicle
{
  protected static $singleTableType = 'truck';
}

There are four required properties to be defined in your classes:

Define the database table

In the root model set the protected property $table to define which database table to use to store all your classes.
Note: even if you are using the default for the root class (i.e. the 'vehicles' table for the Vehicle class) this is required so that subclasses inherit the same setting rather than defaulting to their own table name.

Define the database column to store the class type

In the root model set the protected static property $singleTableTypeField to define which database column to use to store the type of each class.

Define the subclasses

In the root model and each branch model define the protected static property $singleTableSubclasses to define which subclasses are part of the classes hierarchy.

Define the values for class type

In each concrete class set the protected static property $singleTableType to define the string value for this class that will be stored in the $singleTableTypeField database column.

Multi Level Class Hierarchies

It's not uncommon to have many levels in your class hierarchy. Its easy to define that structure by declaring subclasses at each level. For example suppose you have a Vehicle super class with two subclasses Bike and MotorVehicle. MotorVehicle in trun has two subclasses Car and Truck. You would define the classes like this:

use Nanigans\SingleTableInheritance\SingleTableInheritanceTrait;

class Vehicle extends Model
{
  use SingleTableInheritanceTrait;

  protected $table = "vehicles";

  protected static $singleTableTypeField = 'type';

  protected static $singleTableSubclasses = [MotorVehicle::class, Bike::class];
}

class MotorVehicle extends Vehicle
{
  protected static $singleTableSubclasses = [Car::class, Truck::class];
}

class Car extends MotorVehicle
{
  protected static $singleTableType = 'car';
}

class Truck extends MotorVehicle
{
  protected static $singleTableType = 'truck';
}

class Bike extends Vehicle
{
  protected static $singleTableType = 'bike';
}

Defining Which Attributes Are Persisted

Eloquent is extremely lenient in allowing you to get and set attributes. There is no mechanism to declare the set of attributes that a model supports. If you misuse and attribute it typically results in a SQL error if you try to issue an insert or update for a column that doesn't exist. By default the SingleTableInheritanceTrait operates the same way. However, when storing a class hierarchy in a single table there are often database columns that don't apply to all classes in the hierarchy. That Eloquent will store values in those columns makes it considerably easier to write bugs. There, the SingleTableInheritanceTrait allows you to define which attributes are persisted. The set of persisted attributes is also inherited from parent classes.

class Vehicle extends Model
{
  protected static $persisted = ['color']
}

class MotorVehicle extends Vehicle
{
  protected static $persisted = ['fuel']
}

In the above example the class Vehicle would persist the attribute color and the class MotorVehicle would persist both color and fuel.

Automatically Persisted Attributes

For convenience the model primary key and any dates are automatically added to the list of persisted attributes.

BelongsTo Relations

If you are restricting the persisted attribute and your model has BelongsTo relations then you must include the foreign key column of the BelongsTo relation. For example:

class Vehicle extends Model
{
  protected static $persisted = ['color', 'owner_id'];
  
  public function owner()
  {
    return $this->belongsTo('User', 'owner_id');
  }
}

Unfortunately there is no efficient way to automatically detect BelongsTo foreign keys.

Throwing Exceptions for Invalid Attributes

BY default the SingleTableInheritanceTrait will handle invalid attributes silently It ignores non-persisted attributes when a model is saved and ignores non-persisted columns when hydrating a model from a builder query. However, you can force exceptions to be thrown when invalid attributes are encountered in either situation by setting the $throwInvalidAttributeExceptions property to true.

/**
 * Whether the model should throw an InvalidAttributesException if non-persisted 
 * attributes are encountered when saving or hydrating a model.
 * If not set, it will default to false.
 *
 * @var boolean
 */
protected static $throwInvalidAttributeExceptions = true;

Inspiration

We've chosen a very particular implementation to support single table inheritance. However, others have written code and articles around a general approach that proved influential.

First, Mark Smith has an excellent article (no long live but available in web archive) Single Table Inheritance in Laravel 4 amongst other things is introduces the importance of queries returning objects of the correct type. Second, Jacopo Beschi wrote and extension of Eloquent's Model, Laravel-Single-Table-Inheritance`, that introduces the importance of being able to define which attributes each model persists.

The use of Traits was heavy influence by the Eloquent's SoftDeletingTrait and the excellent Validating Trait.

Comments
  • New from builder bugfix

    New from builder bugfix

    This fixes a bug where if you were to use newFromBuilder with an array (as it is designed), an exception will be thrown saying that you cannot use newFromBuilder without a value for the typeField, despite actually having a value for that typeField. The problem was that the overwritten method for newFromBuilder was operating on an object instead of an array. To ensure that this is backwards compatible for cases that use newFromBuilder with an object, I updated the override to cast attributes to an array first. This allows all old tests and the new test to pass. You can see the bug illustrated if you run tests without the committed bugfix.

    opened by bryanashley 12
  • V0.8.6 causes incorrect attributes to be set

    V0.8.6 causes incorrect attributes to be set

    Hey,

    Thanks for the great package.

    Since v.0.8.6 we are encountering some strange behaviour with getAttribute.

    It seems $this->setRawAttributes($filteredAttributes, true); call in nanigans/single-table-inheritance/src/SingleTableInheritanceTrait.php:226 now only sets the pivot attributes instead of all attributes.

    As a result, all normal attributes of the model (not loaded via pivot) are unavailable

    I'm not fully understanding the full scope of the change, but shouldn't $filteredAttributes = array_intersect_key($attributes, array_flip($persistedAttributes)); be $filteredAttributes = array_diff_key($attributes, array_flip($persistedAttributes));?

    For now, I'll redefine

        protected function getPivotAttributeNames($attributes)
        {
            return [];
        }
    

    to fix it on our end, but I suppose It should be fixed here as well

    Regards, Levi

    opened by Levivb 11
  • Feature allow create from base model

    Feature allow create from base model

    Hi! :)

    Please consider adding this as a new feature. This allows to create child models using base models create method. My use case for this is when using DB seeders.

    E.g.:

    foreach (['car', 'truck'] as $type) {
        Vehicle::create(compact('type'));
    }
    
    opened by grzegorz-rozycki 10
  • Can't create a new record directly form the base class

    Can't create a new record directly form the base class

    I need to be able to create a record directly from the base class because of the code base that I have.

    Obviously I can't refactor everything with out a step by step process. And also I need to support a type field without a class, like 'other' for instance. I know this issue/proposal was also asked here: #47 and I know the trade offs this could imply. We love this trait but as I've already said, having a large base code we need some way to add functionality in small cycles. I'm not the only one in this situation.

    Right now we have a workaround idea. For those how have the same problem. A Base Class that extends from Model in which we have all of our configuration then the normal class extends from it and then the inheritances. So we can call the base anywhere without problems.

    class VehicleBase extends Model
    {
        protected $table = "vehicles";
    
        // Conventional Model configuration goes here
    }
    
    
    use Nanigans\SingleTableInheritance\SingleTableInheritanceTrait;
    
    class Vehicle extends VehicleBase
    {
        use SingleTableInheritanceTrait;
    
        protected static $singleTableTypeField = 'type';
        protected static $singleTableSubclasses = [Car::class, Truck::class];
    }
    
    
    class Car extends Vehicle
    {
        protected static $singleTableType = 'car';
    }
    
    
    class Truck extends Vehicle
    {
        protected static $singleTableType = 'truck';
    }
    

    Anyway, This is a great tool. Thanks for the work

    opened by mazyvan 10
  • SingleTableInheritance should not filter out pivot attributes

    SingleTableInheritance should not filter out pivot attributes

    Thanks for a great package, we have started to use it a lot.

    Recently we have run into a problem when using Single Table Inheritance with Eloquent eager loading.

    If I run the below I would get the expected results.

    $model = new App\MyModel;
    $model = $model->find(1);
    dd($model->myRelation);
    

    If I run the below I would get an empty result.

    $model = new App\MyModel;
    $model = $model->with('myRelation')->find(1);
    dd($model->myRelation);
    

    After some digging I found that it worked when there was no Single Table Inheritance.

    This lead me to find that SingleTableInheritanceTrait::setFilteredAttributes() was stripping the pivot table attributes add by the relation.

    $attributes before filtering.

    Array
    (
        [id] => 21
        [type] => sub_type
        [user_id] => 1
        [name] => Test Information
        [description] =>     
        [status] => true
        [created_at] => 2016-10-01 10:56:39
        [updated_at] => 2016-10-01 10:56:39
        [pivot_parent] => 6
        [pivot_other_id] => 21
        [pivot_extra] => 0
    )
    

    $attributes after filtering.

    Array
    (
        [id] => 21
        [type] => sub_type
        [user_id] => 1
        [name] => Test Information
        [description] =>     
        [status] => true
        [created_at] => 2016-10-01 10:56:39
        [updated_at] => 2016-10-01 10:56:39
    )
    

    All pivot attributes are added with the hard coded prefix pivot_. The below code works for me.

      public function setFilteredAttributes(array $attributes) {
        $persistedAttributes = $this->getPersistedAttributes();
        if (empty($persistedAttributes)) {
          $filteredAttributes = $attributes;
        } else {
          // The query often include a 'select *' from the table which will return null for columns that are not persisted.
          // If any of those columns are non-null then we need to filter them our or throw and exception if configured.
          // array_flip is a cute way to do diff/intersection on keys by a non-associative array
          $extraAttributes = array_filter(array_diff_key($attributes, array_flip($persistedAttributes)), function($value) {
            return !is_null($value);
          });
          if (!empty($extraAttributes) && $this->getThrowInvalidAttributeExceptions()) {
            throw new SingleTableInheritanceInvalidAttributesException("Cannot construct " . get_called_class() . ".", $extraAttributes);
          }
    
          $filteredAttributes = array_intersect_key($attributes, array_flip($persistedAttributes));
        }
    +
    +    // All pivot attributes start with 'pivot_'
    +    // Add pivot attributes back in
    +    $filteredAttributes += $this->getPivotAttributeNames($attributes);
    
        $this->setRawAttributes($filteredAttributes, true);
      }
    
    +  protected function getPivotAttributeNames($attributes)
    +  {
    +    $pivots = [];
    +    foreach ($attributes as $key => $value) {
    +      if (starts_with($key, 'pivot_')) {
    +        array_set($pivots, $key, $value);
    +      }
    +    }
    +    return $pivots;
    +  }
    

    Alternatively you could documenting that when eager loading is used and if a relation adds pivot properties using the withPivot() method then all all keys and additional properties need to be added to the $persisted array property.

    My preference would be to not filter out pivot_* properties.

    opened by JFossey 7
  • Cannot insert record

    Cannot insert record

    Hi,

    I noticed there is no record inserted into database..but if I changed the below: $instance = (new $class)->newInstance([], true); to $instance = (new $class)->newInstance();

    it works. However, the changes will pose the update problem for duplicate entry in database.

    Any help will be much appreciated:) Thanks

    opened by andyyapwl 6
  • Laravel 5.7 Support

    Laravel 5.7 Support

    As the title says, it would be very nice to have Laravel 5.7 support as it has been tagged today. From what I've seen in the upgrade guide, not too much has changed, so perhaps it's nothing more than a composer.json update.

    edit: Added a PR: #53

    opened by DenzoNL 6
  • Typemap refactor

    Typemap refactor

    Refactors how the type map is built to fix #20. The type names are now defined as keys on the subclass array rather than in the model.

    All of the tests pass with some slightly refactored to handle the change.

    This is a breaking change.

    opened by alexwhitman 6
  • Enhence performance when querying root model of subclasses

    Enhence performance when querying root model of subclasses

    I found that if model has subclasses, trait adds whereIn condition to query.

    But the root model (which queries every type) don't need whereIn condition to select specific types. It will just make query slow.

    I think it would be nice to remove whereIn condition when querying root model.

    opened by darron1217 5
  • Can we set the type of model using the base model?

    Can we set the type of model using the base model?

    can we set the type of model using the base model. this one is failing to save unless i define a protected static $singleTableType = 'test'; in the base model.

    what will this do is it will resolve to that type of model when retreive again. i want to avoid using if else statement to instantiate the child class.

    class Base extends Model {
    
        use SingleTableInheritanceTrait;
    
        protected static $singleTableTypeField = 'type';
    
        protected static $singleTableSubclasses = [
            Child::class,
        ];
    
        protected $fillable = [
            'name',
            'description',
            'type'
        ];
    }
    ....
    $base= new Base();
    $base->name = 'sample name';
    $base->description = 'sample description';
    $base->type = 'child'; // this one 
    $base->save();
    
    wontfix 
    opened by aiakane16 5
  • belongsToMany relation doesn't load

    belongsToMany relation doesn't load

    Hello, I met a strange behavior applying SingleTableInheritanceTrait on a model used in a BelongsToMany relation:

    Single table inheritance structure:

    class Animal extends Model {
        use SingleTableInheritanceTrait;
    }
    
    class Elephant extends Animal { }
    

    Many to Many relation with "single table inherited" model:

    class Zoo extends Model { 
    
        public function animals(){
            return $this->belongsToMany(Animal::class, 'animal_zoo', 'zoo_id', 'animal_id');
        }
    
    }
    

    The relation load only when I explicitly "touch" the relation property:

    return $zoo->animals;
    

    It doesn't load using "with" or "load" eloquent methods:

    // Create relations
    Zoo::find(1)->animals()->attach([1, 2, 3]);
    
    // Using "with"
    return Zoo::with('animals')->get();
    
    // Using "load" in booted model 
    $zoo = Zoo::find(1);
    return $zoo->load('animals');
    
    /*
     * In both case the result is
     */
    {
      name: 'Super funny Zoo',
      address: '...',
      animals: [] // <-- empty array for belonsToMany relation using SingleTableInheritanceTrait 
    }
    

    This example represents a return value for an api call by javascript frontend. It's an easy reproducible issue.

    Can I use some tricks to load belongsToMany relation properly?

    opened by ilvalerione 5
  • onlyTrashed()->forceDelete() seems to lose the SingleTableInheritance scope, deletes way more than intended

    onlyTrashed()->forceDelete() seems to lose the SingleTableInheritance scope, deletes way more than intended

    I'm planning on working this until I have a proposed patch, but wanted to give a heads up and see if you had any advice.

    Here's my reproduction case in my own codebase: (TP_TradeEstimate and Hours are both subclasses of the same thing)

        public function testOnlyTrashedShouldntBreakSingleTableInheritance(): void
        {
            $liveTP = TP_TradeEstimate::factory()->create();
            $deletedTP = TP_TradeEstimate::factory()->create();
            $deletedTP->delete();
            $liveOther = Hours::factory()->create();
            $deletedOther = Hours::factory()->create();
            $deletedOther->delete();
    
            self::assertSame(1, TP_TradeEstimate::onlyTrashed()->count());
            $affectedRows = TP_TradeEstimate::onlyTrashed()->forceDelete();
            self::assertSame(1, $affectedRows, 'Affected rows should match counted rows');
    
            self::assertModelExists($liveTP);
            self::assertFalse(
                DB::table('account_integrations')
                    ->where('id', $deletedTP->id)
                    ->exists(),
            );
            self::assertModelExists($liveOther);
            self::assertSoftDeleted($deletedOther);
        }
    

    This fails because $affectedRows is actually 2, and if you comment out that assertion, the last assertion fails because $deletedOther has been forceDeleted. Even weirder, obviously onlyTrashed by itself isn't the problem, that count works, and if you get() the collection and do each->forceDelete() there's no problem.

    Using single-table-inheritance v1.0.0 with Laravel framework 9.36.2

    opened by jwadhams 2
  • Fix: Unable to refresh a collection containing mixed types

    Fix: Unable to refresh a collection containing mixed types

    Ran into an issue when trying to refresh an Eloquent collection that contains a mixture of types. The original implementation uses the first element of the collection to hydrate the rest of the records' array into fresh models, and unfortunately that means that will fail eventually.

    This is the result of a failing test I wrote to illustrate this:

    1) Nanigans\SingleTableInheritance\Tests\SingleTableInheritanceTraitCollectionTest::testRefresh
    Nanigans\SingleTableInheritance\Exceptions\SingleTableInheritanceException: Cannot construct newFromBuilder for unrecognized type=bike
    
    /Users/bezalel/Development/single-table-inheritance/src/SingleTableInheritanceTrait.php:195
    /Users/bezalel/Development/single-table-inheritance/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Builder.php:363
    /Users/bezalel/Development/single-table-inheritance/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Builder.php:362
    /Users/bezalel/Development/single-table-inheritance/vendor/laravel/framework/src/Illuminate/Support/Traits/ForwardsCalls.php:23
    /Users/bezalel/Development/single-table-inheritance/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.php:2132
    /Users/bezalel/Development/single-table-inheritance/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Builder.php:625
    /Users/bezalel/Development/single-table-inheritance/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Builder.php:609
    /Users/bezalel/Development/single-table-inheritance/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Collection.php:391
    /Users/bezalel/Development/single-table-inheritance/tests/SingleTableInheritanceTraitCollectionTest.php:41
    

    I propose introducing this trait that can be used in a custom Eloquent builder attached to models that use single-table inheritance. See what's done in the Vehicle model fixture and the VehicleBuilder class.

    opened by bezhermoso 1
  • Create a test case for mutated attributes

    Create a test case for mutated attributes

    These changes show a failure test case for newFromBuilder when an object has mutated attributes.

    This basically comes from using the setRawAttributesFunction here when filtering out attributes.

    If you used a loop for setting attributes, I think this could be fixed.

    Related to #70

    opened by sarahmarshy 1
  • `newFromBuilder` does not maintain mutated attributes

    `newFromBuilder` does not maintain mutated attributes

    https://github.com/jonspalmer/single-table-inheritance/blob/main/src/SingleTableInheritanceTrait.php#L188

    Constructing this object with no attributes and then setting them as "filtered" does not mutate properties that are intended to be mutated.

    I have verified that passing the attributes directly to the constructor does mutate the intended properties.

    opened by sarahmarshy 2
Releases(v1.0.0)
Owner
Jon Palmer
Jon Palmer
Tag support for Laravel Eloquent models - Taggable Trait

Laravel Taggable Trait This package is not meant to handle javascript or html in any way. This package handles database storage and read/writes only.

Rob 859 Dec 11, 2022
ODM with inheritance and OOP composition for Laravel 5+

ODM with inheritance and OOP composition for Laravel 5+ Full Documentation | CHANGELOG LODM module is intended to bring the Spiral ODM component funct

Anton Titov 21 Aug 17, 2022
This package provides a trait that will generate a unique uuid when saving any Eloquent model.

Generate slugs when saving Eloquent models This package provides a trait that will generate a unique uuid when saving any Eloquent model. $model = new

Abdul Kudus 2 Oct 14, 2021
Trait for Laravel testing to count/assert about database queries

counts_database_queries Trait for Laravel testing to count/assert about database queries Installing composer require ohffs/counts-database-queries-tra

null 1 Feb 23, 2022
Generate and autoload custom Helpers, Builder Scope, Service class, Trait

laravel-make-extender Generate and autoload custom helpers, It can generate multilevel helpers in the context of the directory. Generate Service class

Limewell 30 Dec 24, 2022
Trait for multilingual resource file support

⚡ Usage This library supports MultilingualResourceTrait which can be used in PluginBase. Multilingual support of resource files is possible using this

PocketMine-MP projects of PresentKim 1 Jun 7, 2022
This package provides a trait to run your tests against a MinIO S3 server.

Laravel MinIO Testing Tools This package provides a trait to run your tests against a MinIO S3 server. ?? Blog post: https://protone.media/en/blog/how

Protone Media 7 Oct 12, 2022
Livewire trait (throttling). Limiting request processing speed

Livewire Throttling Installation You can install the package via composer: composer require f1uder/livewire-throttling Usage Livewire component <?php

Fluder 5 Dec 7, 2022
Razorpay payment gateway integration in laravel with submit form and storing details in payment table.

Integrating razorpay payment gateway in laravel with submit form and storing payment details in payment table. How to settup the project in your local

Mohammed-Thamnees 3 Apr 15, 2021
Laravel Grid is a package that helps you display table data.

Laravel Grid Laravel Grid is a package that helps you display table data. I could not find package that would satisfy my needs so I decided to write o

null 9 Nov 29, 2022
A make:pivot command to create a pivot table with Laravel

make:pivot for Laravel Installation Requires PHP 8.0.0+ You can install the package via composer: composer require felixdorn/laravel-make-pivot-table

Félix Dorn 13 Aug 23, 2022
A dynamic table component for Laravel Livewire - For Slack access, visit:

A dynamic Laravel Livewire component for data tables. Bootstrap 4 Demo | Bootstrap 5 Demo | Tailwind Demo | Demo Repository Installation You can insta

Anthony Rappa 1.3k Jan 1, 2023
Laravel Larex lets you translate your whole Laravel application from a single CSV file.

Laravel Larex Translate Laravel Apps from a CSV File Laravel Larex lets you translate your whole Laravel application from a single CSV file. You can i

Luca Patera 68 Dec 12, 2022
Library for generating random names (for table-top roleplaying games)

RPG-Name-Generator The RPG character name generator library is designed to create list of random names used for table-top role-playing games. This lib

Florent Genette 2 Sep 24, 2022
Data Table package with server-side processing, unlimited exporting and VueJS components

Data Table package with server-side processing, unlimited exporting and VueJS components. Quickly build any complex table based on a JSON template.

Laravel Enso 618 Dec 28, 2022
This package extends Illuminate to provide partitioned table creation in migrations.

Laravel Partitions for Migrations This package extends Illuminate to provide partitioned table creation in migrations for PostgreSQL. Support for othe

ORPTech 9 Oct 24, 2022
Creates a Filament table action.

Filament Action Designed for easy integration and manage of Filament actions. Installation You can install the package via composer: composer require

Mitesh Rathod 10 Jun 14, 2023
Update multiple Laravel Model records, each with it's own set of values, sending a single query to your database!

Laravel Mass Update Update multiple Laravel Model records, each with its own set of values, sending a single query to your database! Installation You

Jorge González 88 Dec 31, 2022
This Package helps you in laravel application to log all desired activity for each request from request entry point to generate response at a single snapshot.

Laravel Scenario Logger This Package helps you in laravel application to log all desired activity for each request from request entry point to generat

Mehrdad Mahdian 6 Sep 27, 2021