Home » Php » php – Get instance of subtype of a model with Eloquent

php – Get instance of subtype of a model with Eloquent

Posted by: admin February 25, 2020 Leave a comment

Questions:

I have an Animal model, based on the animal table.

This table contains a type field, that can contain values such as cat or dog.

I would like to be able to create objects such as :

class Animal extends Model { }
class Dog extends Animal { }
class Cat extends Animal { }

Yet, being able to fetch an animal like this :

$animal = Animal::find($id);

But where $animal would be an instance of Dog or Cat depending on the type field, that I can check using instance of or that will work with type hinted methods. The reason is that 90% of the code is shared, but one can bark, and the other can meow.

I know that I can do Dog::find($id), but it’s not what I want : I can determine the type of the object only once it was fetched. I could also fetch the Animal, and then run find() on the right object, but this is doing two database calls, which I obviously don’t want.

I tried to look for a way to “manually” instantiate an Eloquent model like Dog from Animal, but I could not find any method corresponding. Any idea or method I missed please ?

How to&Answers:

You can use the Polymorphic Relationships in Laravel as explained in Official Laravel Docs. Here is how you can do that.

Define the relationships in the model as given

class Animal extends Model{
    public function animable(){
        return $this->morphTo();
    }
}

class Dog extends Model{
    public function animal(){
        return $this->morphOne('App\Animal', 'animable');
    }
}

class Cat extends Model{
    public function animal(){
        return $this->morphOne('App\Animal', 'animable');
    }
}

Here you’ll need two columns in the animals table, first is animable_type and another is animable_id to determine the type of model attached to it at runtime.

You can fetch the Dog or Cat model as given,

$animal = Animal::find($id);
$anim = $animal->animable; //this will return either Cat or Dog Model

After that, you can check the $anim object’s class by using instanceof.

This approach will help you for future expansion if you add another animal type (i.e fox or lion) in the application. It will work without changing your codebase. This is the correct way to achieve your requirement. However, there is no alternative approach to achieve polymorphism and eager loading together without using a polymorphic relationship. If you don’t use a Polymorphic relationship, you’ll end up with more then one database call. However, if you have a single column that differentiates the modal type, maybe you have a wrong structured schema. I suggest you improve that if you want to simplify it for future development as well.

Rewriting the model’s internal newInstance() and newFromBuilder() isn’t a good/recommended way and you have to rework on it once you’ll get the update from framework.

Answer:

I think you could override the newInstance method on the Animal model, and check the type from the attributes and then init the corresponding model.

    public function newInstance($attributes = [], $exists = false)
    {
        // This method just provides a convenient way for us to generate fresh model
        // instances of this current model. It is particularly useful during the
        // hydration of new objects via the Eloquent query builder instances.
        $modelName = ucfirst($attributes['type']);
        $model = new $modelName((array) $attributes);

        $model->exists = $exists;

        $model->setConnection(
            $this->getConnectionName()
        );

        $model->setTable($this->getTable());

        $model->mergeCasts($this->casts);

        return $model;
    }

You’ll also need to override the newFromBuilder method.


    /**
     * Create a new model instance that is existing.
     *
     * @param  array  $attributes
     * @param  string|null  $connection
     * @return static
     */
    public function newFromBuilder($attributes = [], $connection = null)
    {
        $model = $this->newInstance([
            'type' => $attributes['type']
        ], true);

        $model->setRawAttributes((array) $attributes, true);

        $model->setConnection($connection ?: $this->getConnectionName());

        $model->fireModelEvent('retrieved', false);

        return $model;
    }

Answer:

If you really want to do this, you could use the following approach inside your Animal model.

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Animal extends Model
{

    // other code in animal model .... 

    public static function __callStatic($method, $parameters)
    {
        if ($method == 'find') {
            $model = parent::find($parameters[0]);

            if ($model) {
                switch ($model->type) {
                    case 'dog':
                        return new \App\Dog($model->attributes);
                    case 'cat':
                        return new \App\Cat($model->attributes);
                }
                return $model;
            }
        }

        return parent::__callStatic($method, $parameters);
    }
}

Answer:

As the OP stated inside his comments: The database design is already set and therefore Laravel’s Polymorphic Relationships seems not to be an option here.

I like the answer of Chris Neal because I had to do something similar recently (writing my own Database Driver to support Eloquent for dbase/DBF files) and gained a lot experience with the internals of Laravel’s Eloquent ORM.

I’ve added my personal flavour to it to make the code more dynamic while keeping an explicit mapping per model.

Supported features which I quickly tested:

  • Animal::find(1) works as asked in your question
  • Animal::all() works as well
  • Animal::where(['type' => 'dog'])->get() will return AnimalDog-objects as a collection
  • Dynamic object mapping per eloquent-class which uses this trait
  • Fallback to Animal-model in case there is no mapping configured (or a new mapping appeared in the DB)

Disadvantages:

  • It’s rewriting the model’s internal newInstance() and newFromBuilder() entirely (copy and paste). This means if there will be any update from the framework to this member functions you’ll need to adopt the code by hand.

I hope it helps and I’m up for any suggestions, questions and additional use-cases in your scenario. Here are the use-cases and examples for it:

class Animal extends Model
{
    use MorphTrait; // You'll find the trait in the very end of this answer

    protected $morphKey = 'type'; // This is your column inside the database
    protected $morphMap = [ // This is the value-to-class mapping
        'dog' => AnimalDog::class,
        'cat' => AnimalCat::class,
    ];

}

class AnimalCat extends Animal {}
class AnimalDog extends Animal {}

And this is an example of how it can be used and below the respective results for it:

$cat = Animal::find(1);
$dog = Animal::find(2);
$new = Animal::find(3);
$all = Animal::all();

echo sprintf('ID: %s - Type: %s - Class: %s - Data: %s', $cat->id, $cat->type, get_class($cat), $cat, json_encode($cat->toArray())) . PHP_EOL;
echo sprintf('ID: %s - Type: %s - Class: %s - Data: %s', $dog->id, $dog->type, get_class($dog), $dog, json_encode($dog->toArray())) . PHP_EOL;
echo sprintf('ID: %s - Type: %s - Class: %s - Data: %s', $new->id, $new->type, get_class($new), $new, json_encode($new->toArray())) . PHP_EOL;

dd($all);

which results the following:

ID: 1 - Type: cat - Class: App\AnimalCat - Data: {"id":1,"type":"cat"}
ID: 2 - Type: dog - Class: App\AnimalDog - Data: {"id":2,"type":"dog"}
ID: 3 - Type: new-animal - Class: App\Animal - Data: {"id":3,"type":"new-animal"}

// Illuminate\Database\Eloquent\Collection {#1418
//  #items: array:2 [
//    0 => App\AnimalCat {#1419
//    1 => App\AnimalDog {#1422
//    2 => App\Animal {#1425

And in case you want you use the MorphTrait here is of course the full code for it:

<?php namespace App;

trait MorphTrait
{

    public function newInstance($attributes = [], $exists = false)
    {
        // This method just provides a convenient way for us to generate fresh model
        // instances of this current model. It is particularly useful during the
        // hydration of new objects via the Eloquent query builder instances.
        if (isset($attributes['force_class_morph'])) {
            $class = $attributes['force_class_morph'];
            $model = new $class((array)$attributes);
        } else {
            $model = new static((array)$attributes);
        }

        $model->exists = $exists;

        $model->setConnection(
            $this->getConnectionName()
        );

        $model->setTable($this->getTable());

        return $model;
    }

    /**
     * Create a new model instance that is existing.
     *
     * @param array $attributes
     * @param string|null $connection
     * @return static
     */
    public function newFromBuilder($attributes = [], $connection = null)
    {
        $newInstance = [];
        if ($this->isValidMorphConfiguration($attributes)) {
            $newInstance = [
                'force_class_morph' => $this->morphMap[$attributes->{$this->morphKey}],
            ];
        }

        $model = $this->newInstance($newInstance, true);

        $model->setRawAttributes((array)$attributes, true);

        $model->setConnection($connection ?: $this->getConnectionName());

        $model->fireModelEvent('retrieved', false);

        return $model;
    }

    private function isValidMorphConfiguration($attributes): bool
    {
        if (!isset($this->morphKey) || empty($this->morphMap)) {
            return false;
        }

        if (!array_key_exists($this->morphKey, (array)$attributes)) {
            return false;
        }

        return array_key_exists($attributes->{$this->morphKey}, $this->morphMap);
    }
}

Answer:

I think I know what you’re looking for. Consider this elegant solution which uses Laravel query scopes, see https://laravel.com/docs/6.x/eloquent#query-scopes for additional information:

Create a parent class that holds shared logic:

class Animal extends \Illuminate\Database\Eloquent\Model
{
    const TYPE_DOG = 'dog';
    const TYPE_CAT = 'cat';
}

Create a child (or multiple) with a global query scope and a saving event handler:

class Dog extends Animal
{
    public static function boot()
    {
        parent::boot();

        static::addGlobalScope('type', function(\Illuminate\Database\Eloquent\Builder $builder) {
            $builder->where('type', self::TYPE_DOG);
        });

        // Add a listener for when saving models of this type, so that the `type`
        // is always set correctly.
        static::saving(function(Dog $model) {
            $model->type = self::TYPE_DOG;
        });
    }
}

(same applies to another class Cat, just replace the constant)

The global query scope acts as a default query modification, such that the Dog class will always look for records with type='dog'.

Say we have 3 records:

- id:1 => Cat
- id:2 => Dog
- id:3 => Mouse

Now calling Dog::find(1) would result in null, because the default query scope will not find the id:1 which is a Cat. Calling Animal::find(1) and Cat::find(1) will both work, although only the last one gives you an actual Cat object.

The nice thing of this setup is that you can use the classes above to create relations like:

class Owner
{
    public function dogs()
    {
        return $this->hasMany(Dog::class);
    }
}

And this relation will automatically only give you all the animals with the type='dog' (in the form of Dog classes). The query scope is automatically applied.

In addition, calling Dog::create($properties) will automatically set the type to 'dog' due to the saving event hook (see https://laravel.com/docs/6.x/eloquent#events).

Note that calling Animal::create($properties) does not have a default type so here you need to set that manually (which is to be expected).

Answer:

Although you are using Laravel, in this case, I think you should not stick to Laravel short-cuts.

This problem you are trying to solve is a classic problem that many other languages/frameworks solve using Factory method pattern (https://en.wikipedia.org/wiki/Factory_method_pattern).

If you want to have your code easier to understand and with no hidden tricks, you should use a well-known pattern instead of hidden/magic tricks under the hood.

Answer:

The easiest way yet is to make method in Animal class

public function resolve()
{
    $model = $this;
    if ($this->type == 'dog'){
        $model = new Dog();
    }else if ($this->type == 'cat'){
        $model = new Cat();
    }
    $model->setRawAttributes($this->getAttributes(), true);
    return $model;
}

Resolving model

$animal = Animal::first()->resolve();

This will return instance of class Animal, Dog or Cat depending on model type