21

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 ?

Clément Malet
  • 5,062
  • 3
  • 29
  • 48
  • @B001ᛦ Of course, my Dog or Cat class is going to have corresponding interfaces, I don't see how it helps here ? – Clément Malet Feb 12 '20 at 15:32
  • @ClmentM Looks like one to many polymorphic relationship https://laravel.com/docs/6.x/eloquent-relationships#one-to-many-polymorphic-relations – nice_dev Feb 12 '20 at 15:40
  • @vivek_23 Not really, in this case it helps to filter comments of a given type, but you already know that you want comments in the end. Doesn't apply here. – Clément Malet Feb 12 '20 at 15:49
  • @ClmentM I think it does. Animal can be either Cat or Dog. So, when you retrieve the animal type from animal table, it would give you an instance of either Dog or Cat depending upon what is stored in the database. The last line there says _The commentable relation on the Comment model will return either a Post or Video instance, depending on which type of model owns the comment._ – nice_dev Feb 12 '20 at 15:51
  • @vivek_23 I dived more into the documentation and gave it a try, but Eloquent is based on actual column with `*_type` name to determine the subtype model. In my case I really have only one table, so while it's a nice feature, not in my case. – Clément Malet Feb 12 '20 at 16:12
  • Check this: https://stackoverflow.com/questions/34700373/laravel-5-dynamically-create-eloquent-models – flakerimi Feb 24 '20 at 07:36

7 Answers7

9

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);
    }
}

Christoph Kluge
  • 1,947
  • 8
  • 23
  • Just out of curiosity. Does this also work with Animal::all() Is the resulting collection a mixture of 'Dogs' and 'Cats'? – shock_gone_wild Feb 18 '20 at 12:46
  • @shock_gone_wild pretty good question! I tested it locally and added it to my answer. Seems to work as well :-) – Christoph Kluge Feb 18 '20 at 16:02
  • 2
    modifying the laravel's built in function isn't correct way. All changes will loose once we update the laravel and it will mess up everything. Be aware. – Kiran Maniya Feb 24 '20 at 06:04
  • 2
    Hey Navin, thank you for mentioning this but it‘s already clearly stated as disadvantage inside my answer. Counter question: What is the correct way then? – Christoph Kluge Feb 24 '20 at 06:46
8

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.

Community
  • 1
  • 1
Kiran Maniya
  • 8,453
  • 9
  • 58
  • 81
  • 1
    In the comments of the question he said, that he only has one table and the polymorphic features are not usable in the case of OP. – shock_gone_wild Feb 18 '20 at 11:34
  • @shock_gone_wild Then it won't be possible to eager load the relationship with eloquent and he will always pass through more than one database calls. – Kiran Maniya Feb 18 '20 at 11:38
  • 3
    I'm just stating, what the given scenario is like. I personally would also use Polymorphic Relationships ;) – shock_gone_wild Feb 18 '20 at 11:41
  • @shock_gone_wild That's correct :-) However, You may probably know that it's the only way. There is no workaround to eager load. – Kiran Maniya Feb 18 '20 at 11:45
  • 1
    @KiranManiya thank you for your detailed answer. I'm interested in more background. Can you elaborate why (1) the questioners database model is wrong and (2) extending public/protected member functions isn't good/recommended? – Christoph Kluge Feb 24 '20 at 08:32
  • 1
    @ChristophKluge, You already know. (1)DB model is wrong in the context of laravel design patterns. If you want to follow the design pattern defined by laravel, you should have DB schema according to it. (2) It's a framework internal method you have suggested to override. I won't do it if I ever face this issue. Laravel framework has built-in polymorphism support so why don't we use that rather re-inventing the wheel? You gave a good clue in the answer but I never preferred code with disadvantage instead we can code something that helps to simplify future expansion. – Kiran Maniya Feb 24 '20 at 09:39
  • 2
    But... the whole question is not about Laravel Design patterns. Again, we have a given scenario (Perhaps the database is created by an external Application). Everyone will agree that polymorphism would be the way to go if you build from scratch. In fact your answer does technically not answer the original question. – shock_gone_wild Feb 24 '20 at 10:07
  • @shock_gone_wild Thanks for pointing that out. I just put my thoughts as a post. I know it's not about design pattern but we should follow it. – Kiran Maniya Feb 24 '20 at 10:09
  • @KiranManiya ok I see where you are coming from, which is totally valid and no offense from my side. I would perhaps make it very clear that this is the "laravel" way - which derives from the question. Thank you for giving heads up on my question. Small sidenote on (2) In my opinion overriding is a totally valid case. It's a core concept of OOP. If the framework would not allow this, then they could make it private right? Perhaps there was a valid reason for it.. who knows.. many brains, many ideas and solutions! :-) – Christoph Kluge Feb 24 '20 at 10:44
  • @ChristophKluge Your sidenote is valid and I also believe in that. It's valid to override it in a modal (as OOP allows :-) ) but I gave high priority to laravel way. – Kiran Maniya Feb 24 '20 at 11:50
5

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;
    }
Chris Neal
  • 59
  • 2
5

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);
    }
}
shock_gone_wild
  • 6,700
  • 4
  • 28
  • 52
3

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).

Flame
  • 6,663
  • 3
  • 33
  • 53
1

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

Ruben Danielyan
  • 728
  • 4
  • 19
-2

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.

rubens21
  • 743
  • 5
  • 13