6

I am building a CMS package for Laravel.

All of my models in this package are bound to, and resolved from, the IoC container so that they can be easily overwritten in any individual deployment of the package.

For non-polymorphic relationships, this has worked a charm.

For instance, a Page has many PageModules, so its relationship changed from:

// \Angel\Core\Page

public function modules()
{
    return $this->hasMany('PageModule');
}

to:

// \Angel\Core\Page

public function modules()
{
    return $this->hasMany(App::make('PageModule'));
}

But I haven't been able to figure out how to do the same thing with polymorphic relationships.

For instance, Menus contain MenuItems, and each MenuItem can be tied to a single other model, such as a Page or BlogPost.

To accomplish this the Laravel way, I've added the following relationship to MenuItem:

// \Angel\Core\MenuItem

public function linkable()
{
    return $this->morphTo();
}

And this relationship to LinkableModel, which all models such as Page and BlogPost extend:

// \Angel\Core\LinkableModel

public function menuItem()
{
    return $this->morphOne(App::make('MenuItem'), 'linkable');
}

And the menus_items table (which MenuItems use) has these rows:

linkable_type      |  linkable_id
-------------------|--------------
\Angel\Core\Page   |   11
\Angel\Core\Page   |   4

This works great, but I need the linkable_type to say 'Page' instead of '\Angel\Core\Page', and to be resolved from the IoC's 'Page' instead of being hard-coded to a particular namespaced class.

What I've Tried:

According to this question, it should be as easy as defining a $morphClass property to the linkable() classes, like so:

// \Angel\Core\Page
protected $morphClass = 'Page';

But when I apply this, and change the menus_items table to look like this:

linkable_type  |  linkable_id
---------------|--------------
Page           |   11
Page           |   4

...I simply get a Class 'Page' not found. error whenever linkable() is called on MenuItem.

This is the exact line in Eloquent that throws the error.

So, I dug into Eloquent and thought I might be able to get away with something like this:

// \Angel\Core\MenuItem

public function linkable()
{
    return $this->morphTo(null, App::make($this->linkable_type));
}

...this feels so close, but alas: Eloquent calls linkable() before it's filled the rest of the MenuItem's attributes / columns, so $this->linkable_type is null and therefore will not resolve anything from the IoC.

Thank you so much in advance for any guidance you might have!

Community
  • 1
  • 1
Leng
  • 2,948
  • 2
  • 21
  • 30

1 Answers1

4
public function linkable()
{
    return $this->morphTo(null, App::make($this->linkable_type));
}

This will not work in any case, because morphTo() in Illuminate\Database\Eloquent\Model expects

  1. The name of the polymorphic relationship ('linkable')
  2. The type of the object to be morphed ('Page', not an instance of Page)
  3. The id of the object to be morphed

If they are not provided, Laravel is smart enough to guess them and then accordingly return a Illuminate\Database\Eloquent\MorphTo object.

Also, $this->linkable_type and $this->linkable_id should actually not be null in that context.

Let's have a quick look at the relevant part of the morphTo() function:

$instance = new $class;

return new MorphTo(
    $instance->newQuery(), $this, $id, $instance->getKeyName(), $type, $name
);

Note: This is the code from version 4.2.6, the code linked above seems to be from a later version and is slightly different and the function returns a BelongsTo instead of a MorphTo object.

The problem is specifically the $instance = new $class; - the class is simply instanciated and not resolved. But you can just grab that part of the magic and handle it yourself:

public function linkable()
{
    $instance = App::make($this->linkable_type);

    $id = 'linkable_id';
    $type = 'linkable_type';
    $name = 'linkable';

    return new MorphTo(
        $instance->newQuery(), $this, $id, $instance->getKeyName(), $type, $name
    );
}

This should actually work (haven't tested it), but I'm not sure about any side effects it might cause in some edge cases.

Or maybe you could also just override the whole function in your MenuItem-model and just adjust the relevant part:

public function morphTo($name = null, $type = null, $id = null)
{
    if (is_null($name))
    {
        list(, $caller) = debug_backtrace(false);

        $name = snake_case($caller['function']);
    }

    list($type, $id) = $this->getMorphs($name, $type, $id);

    //eager loading
    if (is_null($class = $this->$type))
    {
        return new MorphTo(
            $this->newQuery(), $this, $id, null, $type, $name
        );
    }

    // normal lazy loading
    else
    {
        // this is the changed part
        $instance = \App::make($class); // new $class;

        return new MorphTo(
            $instance->newQuery(), $this, $id, $instance->getKeyName(), $type, $name
        );
    }
}

Note: This works well for lazy-loading, but does not work for eager-loading. An issue has been raised seeking a solution for eager-loading here.

The relationship would then be as usual:

public function linkable()
{
    return $this->morphTo();
}
Quasdunk
  • 14,944
  • 3
  • 36
  • 45
  • You are a genius. Thank you so much for your help. You say that we can't pass in null for name because it wouldn't be able to deduce it, but [this made me think that we it could!](http://laravel.com/api/source-class-Illuminate.Database.Eloquent.Model.html#523) – Leng Jul 12 '14 at 19:29
  • Although I am using Laravel 4.1 which has [this morphTo](https://github.com/laravel/framework/blob/2f1c6cd51dfd51d573977f07e34ba609cc0bf3ac/src/Illuminate/Database/Eloquent/Model.php#L724). – Leng Jul 12 '14 at 19:31
  • Rats! Quasdunk, when I eager-load this relationship, it no longer works because $this->linkable_type is, indeed, null in that situation. I suppose when eager loading, Eloquent calls the relationship methods before filling in the properties/columns. :( I'm going to keep searching for a working solution. – Leng Jul 12 '14 at 19:35
  • @Leng Yep, that's probably one of the side effects I was talking about :) But I guess it should work if you just override the whole function in your model and just change the relevant parts instead of just extracting the lazy-loading-part as suggested before. I've just tested it here, it works fine for me (Laravel 4.2.6). – Quasdunk Jul 12 '14 at 19:38
  • //EDIT - It does not work if I eager load the MenuItems from the Page. Sorry, tested it the wrong way :-P – Quasdunk Jul 12 '14 at 19:44
  • The way I was doing this before was with my own `fmodel` / `fid` columns that I would compile, resolve, and query to eager-load myself, which works great but is awful complex and hard to maintain. I feel like this similar complexity must be somewhere in the MorphTo class for eager loading but I can't quite figure out the stack trace like you did so well for lazy loading... – Leng Jul 12 '14 at 19:47
  • This is my crazy ridiculous `fmodel` / `fid` [code that I may have to end up reverting to](https://github.com/JVMartin/angel/blob/aff772f0ef72e0b9c9efaebbb2b7426442b68ac8/src/models/Menu.php#L55). – Leng Jul 12 '14 at 19:51
  • Oh well... I'm not sure if it's really worth the hassle. Maybe it would be way easier to just define the type of the MenuItem as a property instead of a relation, like 'destinationType' (and the corresponding 'destinationId') or something, and then just handle it yourself. I think this might save you some time and headache :) And maybe you should report / discuss this issue on [GitHub](https://github.com/laravel/framework/issues) and/or on [laravel.io](http://laravel.io/forum). Good luck with your project! – Quasdunk Jul 12 '14 at 20:04
  • Hey thanks again so much for all your help! I'll definitely try the Laravel project's discussions threads and the laravel.io forum. Cheers! – Leng Jul 12 '14 at 20:06