Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Get instance of subtype of a model with Eloquent

Tags:

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 ?

like image 538
Clément Malet Avatar asked Feb 12 '20 15:02

Clément Malet


2 Answers

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);     } }  
like image 118
Christoph Kluge Avatar answered Oct 09 '22 06:10

Christoph Kluge


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.

like image 36
Kiran Maniya Avatar answered Oct 09 '22 07:10

Kiran Maniya