Framework: A flaw in Relation::morphMap with inherited models

Created on 8 Feb 2017  路  5Comments  路  Source: laravel/framework

  • Laravel Version: 5.4
  • PHP Version: 7
  • Database Driver & Version: Mysql

Description:

The problem with morphMap is that you cannot extend a model and use the same map key.

Steps To Reproduce:

Let's say you have a User model with a polymorphic m-t-m relationship with Tags.

User Model looks like

...
public function tags() {
        return $this->morphToMany(Tag::class, 'taggable');
}
...

The morphMap looks like

'users' => 'App\Models\User'

Now let's say you have a SpecialUser model that extends User. They both use the same table
and you want to use the same tags relationship.

This will not work

$specialUser = SpecialUser::find(1);
$specialUser->tags();

Potential Solution:

Restore the deprecated protected $morphClass model member

    public function getMorphClass()
    {

        if ($this->morphClass !== null) {
            return $this->morphClass;
        }
        $morphMap = Relation::morphMap();

        $class = static::class;

        if (! empty($morphMap) && in_array($class, $morphMap)) {
            return array_search($class, $morphMap, true);
        }

        return $class;
    }

Most helpful comment

I think it should depend on the polymorphic relationship's requirement.

There're are 2 situations:

Relate to a concrete model

The related model needs to be aware of the inheritance of its parent.

The inverse lookup needs to reconstruct its relationship as inherited class.

The inheritance should be constant since created.

In this situation:

Try to use the default behavior of the eloquent, or just map for each inherited class.

And try not to use the parent model to initialize the relationship directly (Setup relationship in each inherited class).

class AppServiceProvider
{
    public function boot()
    {
        Relation::morphMap([
            'special' => 'App\Models\SpecialUser',
            'super' => 'App\Models\SuperUser'
        ]);
    }
}

Relate to an abstract model

The related model don't need to be aware of the inheritance of its parent.

The inverse lookup needs to reconstruct its relationship as abstract parent class.

The inheritance may be changed in the object's lifecycle.

And the most important, you should have another object(s) to polymorph with, otherwise you can just use the normal relationship to relate to the abstract model directly.

In this situation:

Try to overwrite the getMorphClass method in the inherited class, and return its parent's class name or mapped name (Don't forget to register mapped name).

class SpecialUser extends User
{
    public function getMorphClass()
    {
        return 'users';
    }     
}

class SuperUser extends User
{
    public function getMorphClass()
    {
        return 'users';
    }     
}

All 5 comments

Make a trait like this:

trait MorphMap
{
    protected static function boot()
    {
        static::bootTraits();

        static::loadMorphMap();
    }

    protected static function loadMorphMap()
    {
        Relation::morphMap([
            'users' => 'App\Models\User',
        ]);
    }
}

Then use this trait in your classes.

After I wrote this issue, I realized there is a slew of issues that come up with you're extending eloquent models. One of the issues is that if you define your relationships via the parent model, then your extended models aren't being used. This causes any polymorphic relationships to break if they are extended. The above code will work when storing the new polymorphic but on the inverse lookup it will not work because it matches App\Models\User instead of your parent model. I came up with a more general solution for extending models, which I can share if anyone's interested.

Welp, I just ran into the exact set of issues you laid out there at the end. I have inherited classes that I want using the same key in the database, with the relations set in the parent class that share a polymorphic relationship with another class. I'm resorting to just removing the polymorphic code and just dealing with two id's for the time being.

I think it should depend on the polymorphic relationship's requirement.

There're are 2 situations:

Relate to a concrete model

The related model needs to be aware of the inheritance of its parent.

The inverse lookup needs to reconstruct its relationship as inherited class.

The inheritance should be constant since created.

In this situation:

Try to use the default behavior of the eloquent, or just map for each inherited class.

And try not to use the parent model to initialize the relationship directly (Setup relationship in each inherited class).

class AppServiceProvider
{
    public function boot()
    {
        Relation::morphMap([
            'special' => 'App\Models\SpecialUser',
            'super' => 'App\Models\SuperUser'
        ]);
    }
}

Relate to an abstract model

The related model don't need to be aware of the inheritance of its parent.

The inverse lookup needs to reconstruct its relationship as abstract parent class.

The inheritance may be changed in the object's lifecycle.

And the most important, you should have another object(s) to polymorph with, otherwise you can just use the normal relationship to relate to the abstract model directly.

In this situation:

Try to overwrite the getMorphClass method in the inherited class, and return its parent's class name or mapped name (Don't forget to register mapped name).

class SpecialUser extends User
{
    public function getMorphClass()
    {
        return 'users';
    }     
}

class SuperUser extends User
{
    public function getMorphClass()
    {
        return 'users';
    }     
}

I combined @leturtle 's answer with these posts :

So i created a trait who implements the getMorphClass() method

trait HasParentModel
{
    public function getTable()
    {
        if (! isset($this->table)) {
            return str_replace('\\', '', Str::snake(Str::plural(class_basename($this->getParentClass()))));
        }
        return $this->table;
    }
    public function getForeignKey()
    {
        return Str::snake(class_basename($this->getParentClass())).'_'.$this->primaryKey;
    }
    public function joiningTable($related)
    {
        $models = [
            Str::snake(class_basename($related)),
            Str::snake(class_basename($this->getParentClass())),
        ];
        sort($models);
        return strtolower(implode('_', $models));
    }
    protected function getParentClass()
    {
        return (new ReflectionClass($this))->getParentClass()->getName();
    }

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


}
Was this page helpful?
0 / 5 - 0 ratings