Laravel: Returning the namespaced owner of a polymorphic relation
Asked Answered
A

9

58

I can find a number of discussions regarding this but no clear solution. Here are two links, although I will cover everything in my own question here.

Github Issues

Laravel.io discussion

Simple Explanation

This is a simple explanation of my problem for anyone already familiar with Laravel's polymorphic relationships.

When using $morphClass, the contents of $morphClass which is saved in the database as the morph "type" is used for the classname when trying to find the owner of a polymorphic relation. This results in an error since the whole point of $morphClass is that it is not a fully namespaced name of the class.

How do you define the classname that the polymorphic relationship should use?

More Detailed Explanation

This is a more detailed explanation explaining exactly what i'm trying to do and why i'm trying to do it with examples.

When using Polymorphic relationships in Laravel whatever is saved as the "morph_type" type in the database is assumed to be the class.

So in this example:

class Photo extends Eloquent {

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

}

class Staff extends Eloquent {

    public function photos()
    {
        return $this->morphOne('Photo', 'imageable');
    }

}

class Order extends Eloquent {

    public function photos()
    {
        return $this->morphOne('Photo', 'imageable');
    }

}

The database would look like this:

staff

 - id - integer
 - name - string

orders

 - id - integer
 - price - integer

photos

 - id - integer
 - path - string
 - imageable_id - integer
 - imageable_type - string

Now the first row of photos might look like this:

id,path,imageable_id,imageable_type

1,image.png,1,Staff

Now I can either access the Photo from a Staff model or a Staff member from a Photo model.

//Find a staff member and dump their photos
$staff = Staff::find(1);

var_dump($staff->photos);

//Find a photo and dump the related staff member
$photo = Photo::find(1);

var_dump($photo->imageable);

So far so good. However when I namespace them I run into a problem.

namespace App/Store;
class Order {}

namespace App/Users;
class Staff {}

namespace App/Photos;
class Photo {}

Now what's saved in my database is this:

id,path,imageable_id,imageable_type

1,image.png,1,App/Users/Staff

But I don't want that. That's a terrible idea to have full namespaced class names saved in the database like that!

Fortunately Laravel has an option to set a $morphClass variable. Like so:

class Staff extends Eloquent {

    protected $morphClass = 'staff';

    public function photos()
    {
        return $this->morphOne('Photo', 'imageable');
    }

}

Now the row in my database will look like this, which is awesome!

id,path,imageable_id,imageable_type

1,image.png,1,staff

And getting the photos of a staff member works absolutely fine.

//Find a staff member and dump their photos
$staff = Staff::find(1);

//This works!
var_dump($staff->photos);

However the polymorphic magic of finding the owner of a photo doesn't work:

//Find a photo and dump the related staff member
$photo = Photo::find(1);

//This doesn't work!
var_dump($photo->imageable);

//Error: Class 'staff' not found

Presumably there must be a way to inform the polymorphic relationship of what classname to use when using $morphClass but I cannot find any reference to how this should work in the docs, in the source code or via Google.

Any help?

Autogiro answered 9/1, 2015 at 17:31 Comment(4)
Did you tried pass to $this->morphOne() model name with namespace?Chrysarobin
Which argument would it be?Autogiro
first, return $this->morphOne('App/Photos/Photo', 'imageable');Chrysarobin
Ah yes i've done that, sorry copy and pasting the whole thing out again didn't seem worthwhile, but that makes no difference. It's the morphTo() that's the problem not the morphOne()Autogiro
L
66

There are 2 easy ways - one below, other one in @lukasgeiter's answer as proposed by Taylor Otwell, which I definitely suggest checking as well:

// app/config/app.php or anywhere you like
'aliases' => [
    ...
    'MorphOrder' => 'Some\Namespace\Order',
    'MorphStaff' => 'Maybe\Another\Namespace\Staff',
    ...
]

// Staff model
protected $morphClass = 'MorphStaff';

// Order model
protected $morphClass = 'MorphOrder';

done:

$photo = Photo::find(5);
$photo->imageable_type; // MorphOrder
$photo->imageable; // Some\Namespace\Order

$anotherPhoto = Photo::find(10);
$anotherPhoto->imageable_type; // MorphStaff
$anotherPhoto->imageable; // Maybe\Another\Namespace\Staff

I wouldn't use real class names (Order and Staff) to avoid possible duplicates. There's very little chance that something would be called MorphXxxx so it's pretty secure.

This is better than storing namespaces (I don't mind the looks in the db, however it would be inconvenient in case you change something - say instead of App\Models\User use Cartalyst\Sentinel\User etc) because all you need is to swap the implementation through aliases config.

However there is also downside - you won't know, what the model is, by just checking the db - in case it matters to you.

Ligula answered 12/1, 2015 at 19:57 Comment(5)
Ah that's a very good solution. I don't have it to test straight away but it makes sense and looks like it should work. I haven't found such a solution described anywhere else :)Autogiro
I don't think anyone came up with it before.Ligula
Assigned you the bounty due to taking the time to come up with a unique solution. Although to anyone reading this later, it's also worth checking out the other answer by @lukasgeiterAutogiro
I always used MyClass::class when defining the morphOne relationship, so I never faced this problem. However, I never thought about the effects of refactoring on the database, thanks!Masseuse
You KO'd the Laravel documentation, I don't think this is in there. I salute you.Incase
H
36

When using Laravel 5.2 (or newer) you can use the new feature morphMap to address this issue. Just add this to the boot function in app/Providers/AppServiceProvider:

Relation::morphMap([
    'post' => \App\Models\Post::class,
    'video' => \App\Models\Video::class,
]);

More about that: https://nicolaswidart.com/blog/laravel-52-morph-map

Hydrostatics answered 9/2, 2016 at 10:4 Comment(2)
This is working for me but I had troubles unsing the MorphMap. Maybe this helps other. If you're using the morphmap in your relationship e.g. photos you can use it like this: add $relation = Relation::morphMap(); before the return to get the map and then return $this->morphOne($relation['photo'], 'imagable');. Not sure if there is an easier way but like this it is working with-out saying photo not found.Acie
Another useful method to setup the morph. Add $table->morphs('imageable'); to your photos migration. That will create imageable_type and imageable_id for you, that's easier to write. Found in the test setup hereAcie
M
29

I like @JarekTkaczyks solution and I would suggest you use that one. But, for the sake of completeness, there's is another way Taylor briefly mentions on github

You can add a attribute accessor for the imageable_type attribute and then use a "classmap" array to look up the right class.

class Photo extends Eloquent {

    protected $types = [
        'order' => 'App\Store\Order',
        'staff' => 'App\Users\Staff'
    ];

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

    public function getImageableTypeAttribute($type) {
        // transform to lower case
        $type = strtolower($type);

        // to make sure this returns value from the array
        return array_get($this->types, $type, $type);
        // for Laravel5.7 or later
        return \Arr::get($this->types, $type, $type);

        // which is always safe, because new 'class'
        // will work just the same as new 'Class'
    }

}

Note that you still will need the morphClass attribute for querying from the other side of the relation though.

Mariken answered 16/1, 2015 at 21:33 Comment(6)
Cheers, I like yours as well. Yours (or Taylor's) is perhaps even better as it's all confined within the Eloquent model rather than needing to use the config files. It's tough choice on who to give the bounty to!Autogiro
Thanks. I would have given the bounty to @JarekTkaczyk's answer as well... It still isn't a very nice solution though and this "bug" still kind of bothers me. If I have some time I'll see if I can figure something out to fix it and make a pull request. I'll definitely leave a comment here if that happens ;)Mariken
Yeah, that's good as well, even better having this in place. Just fix the accessor to make sure there's no problem with letter-case. Mind that $array['Type'] != $array['type'] BUT new 'Type' == new 'type' - welcome to PHP ;)Ligula
@JarekTkaczyk I'm well aware of that. But I don't see in which way I'd have to "fix" the accessor. I don't see the array keys of $types as classnames but rather a case sensitive identifiers. If I misunderstood you, feel free to suggest an edit...Mariken
@JarekTkaczyk Oh yeah that's safer :)Mariken
I had to add '' => self:class to the $types array or else it would complain when first saving the modelWeaver
V
14

This is the way you can get morph class name(alias) from Eloquent model:

(new Post())->getMorphClass()
Vehemence answered 18/9, 2019 at 10:0 Comment(0)
C
3

Let laravel put it into the db - namespace and all. If you need the short classname for something besides making your database prettier then define an accessor for something like :

<?php namespace App\Users;

class Staff extends Eloquent {

    // you may or may not want this attribute added to all model instances
    // protected $appends = ['morph_object'];

    public function photos()
    {
        return $this->morphOne('App\Photos\Photo', 'imageable');
    }

    public function getMorphObjectAttribute()
    {
        return (new \ReflectionClass($this))->getShortName();
    }

}

The reasoning I always come back to in scenarios like this is that Laravel is pretty well tested, and works as expected for the most part. Why fight the framework if you don't have to - particularly if you are simply annoyed by the namespace being in the db. I agree it isn't a great idea to do so, but I also feel that I can spend my time more usefully getting over it and working on domain code.

Countermand answered 13/1, 2015 at 4:9 Comment(4)
You're aware that it's a bug in the well-tested Laravel? There's a method for this, however Eloquent can't handle it in the end :)Ligula
It's not just 'annoyance' of it being in the db. One of the main features of Laravel is that you can easily change what classes you are using and drop different classes in and out. With this scenario if I change the structure of the Code I need to update the database and not just the structure of the database (as is common when making changes), but the actual data in the rows themselves. That's a terrible flow in workflow/design of the app.Autogiro
Well, take it as you will. A quick update call @ the db end solves the changed path problem - once you deploy the likelyhood of the namespace changing for any of those models should be fairly low and the issue of changing them is resolvable with about 30ms of query time from the command line. My point was more that - even if it is a bug, taking the time to fight against it when it works if you don't is a waste of effort at the moment.Countermand
Also, if you feel it is a bug (which it seems it is!) then file a pull request with a fix @ github - or at the very least open an issue and get confirmation that this is not working as expected.Countermand
T
3

In order for eager loading of polymorphic relations eg Photo::with('imageable')->get(); it's necessary to return null if type is empty.

class Photo extends Eloquent {

    protected $types = [
        'order' => 'App\Store\Order',
        'staff' => 'App\Users\Staff'
    ];

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

    public function getImageableTypeAttribute($type) {
        // Illuminate/Database/Eloquent/Model::morphTo checks for null in order
        // to handle eager-loading relationships
        if(!$type) {
            return null;
        }

        // transform to lower case
        $type = strtolower($type);

        // to make sure this returns value from the array
        return array_get($this->types, $type, $type);

        // which is always safe, because new 'class'
        // will work just the same as new 'Class'
    }

}
Tacket answered 17/7, 2015 at 7:45 Comment(1)
Using this method, I found the value of $type has extra tab character in the end "\t".Garlen
G
2

A simple solution will be to alias the value in imageable_type column in the db to the full class namespace as:

// app/config/app.php 
'aliases' => [
...
'Order' => 'Some\Namespace\Order',
'Staff' => 'Maybe\Another\Namespace\Staff',
...
]
Geodetic answered 8/2, 2022 at 9:53 Comment(0)
Q
1

Answering this for those using higher version of Laravel. I just stumbled upon this while re-viewing the AppServiceProvider and trying to make sense of the answers here.

I'm using Laravel 8 for my project and to add an alias to morphing, Relation class is used from Illuminate\Database\Eloquent\Relations\Relation. Within that same class, there's a function called Relation::getMorphedModel($alias) where $alias is the alias of a model provided at AppServiceProvider.

You can use that function to immediately access the class along with its namespace.

Here's an example:

[AppServiceProvider]

use Illuminate\Database\Eloquent\Relations\Relation;

// Uses alias instead of their path names
Relation::morphMap([
    'user' => 'App\User',
    'booking' => 'App\Booking',
    'additional_order' => 'App\AdditionalOrder',
]);

[Controller/Model]

use Illuminate\Database\Eloquent\Relations\Relation;

echo Relation::getMorphedModel('user');
// Returns "App\User"

Qnp answered 24/2, 2023 at 20:16 Comment(0)
I
0

In my case, I wanted to "extend" or "Mock" a model, without breaking all query

Hence for Larvel 7 and maybe above, try something like:

<?php

namespace App;

use Illuminate\Database\Eloquent\Relations\Relation;

class MockUser extends User
{
    protected $guard_name = 'web';

    protected $table = 'users';

    protected static function boot()
    {
        parent::boot();
        // Ensures morph-queries use "App\User" instead of MockUser.
        Relation::morphMap([
            User::class => MockUser::class,
        ]);
    }
}

Note that in above, Laravel maps array-value to array-key (and changes MockUser to User in queries), which's the reverse of what mapping means, but Laravel-devs may have their reasons.

Injunction answered 27/2, 2023 at 14:25 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.