Skip to main content
  1. Programming/

Customize Model Accessor Over Null Object Pattern

Image generated using DALL-E 2

As per Laravel documentation1, the belongsTo, hasOne, hasOneThrough, and morphOne relationships allow us to define a default model if no related model is found. This pattern is known as Null Object Pattern in software engineering world. This can be very useful from time to time and it is very easy to achieve, thanks to the eloquence of Laravel.

Consider the following polymorphic relationship between the User and the File model for a profile picture.

1
2
3
4
public function profilePicture(): MorphOne
{
    return $this->morphOne(File::class, 'fileable')->withDefault();
}

Aside from traditional $this->morphOne(File::class, 'fileable'), we are returning an empty File object using withDefault() method if no corresponding profile picture is found for the user. Let’s take a look inside our File model.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
namespace App\Models;

use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Storage;

class File extends Model
{
    protected $fillable = [
        'name',
        'path',
        'fileable_id',
        'fileable_type',
    ];

    public function fileable(): MorphTo
    {
        return $this->morphTo();
    }

    public function url(): Attribute
    {
        return Attribute::get(function ($value, $attributes) {
            // If path is empty or file is not found in path, return empty string
            if (empty($attributes['path']) || ! Storage::exists($attributes['path'])) {
                return '';
            }

            return Storage::url($attributes['path']);
        });
    }
}

Here we have pretty standard stuff. Some attributes in the $fillable property, the polymorphic relationship fileable() and an accessor url to sort out the actual URL from the path of the file.

Without doing any additional thing to the withDefault() method in User model relationship, we would get an empty File model. Sometimes, this is enough. But often we need more than that. For example, in this case, we would want to generate some sort of SVG for missing profile picture which is a standard practice. We can do so:

1
2
3
4
5
6
7
public function profilePicture(): MorphOne
{
    return $this->morphOne(File::class, 'fileable')->withDefault([
        'name' => "Default User",
        'url' => \Svg::for("Default User")->toUrl(),
    ]);
}

We can pass an array inside withDefault() with the attributes we want to populate. For the url attribute, we are using my SVG Avatar Generator library for Laravel to generate an SVG on the fly based on the string we are passing.

But “Houston, we have a problem”2. All of our users now have same profile picture cause we are passing same “Default User” string. That’s underwhelming. How can we make it more exciting?

Turns out, Laravel makes it very effortless (yeah, the level of attention to detail is 🤯). Instead of array, we can pass a closure to the withDefault(). The closure receives two parameters; first, the related model; and second, the parent model:

1
2
3
4
5
6
7
8
public function profilePicture(): MorphOne
{
    return $this->morphOne(File::class, 'fileable')
            ->withDefault(function (File $file, User $user) {
                $file->name = $user->name;
                $file->path = \Svg::for($user->name)->toUrl();
            });
}

Great! Now all users have customized SVGs based on their names. 💅

In the array we passed the value of url but in the closure we set the value of path. I don’t like to set an accessor value directly, I would rather generate the value based on the path. This is just a personal preference. If you do something like $file->url = Svg::for($user->name)->toUrl() it would work as well.

Also, we have to modify the url accessor on the File model slightly. Since we are setting the URL directly in path, we can no longer just call Storage::url() over another URL. There should be a check if the path is a file path or a URL. For this we can use Laravel’s URL facade.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
namespace App\Models;

//...
use URL;

class File extends Model
{
    //...

    public function url(): Attribute
    {
        return Attribute::get(function ($value, $attributes) {
            // If path is a valid URL, return it
            if (isset($attributes['path']) && URL::isValidUrl($attributes['path'])) {
                return $attributes['path'];
            }

            //...

            return Storage::url($attributes['path']);
        });
    }
}

That’s all. Now if we set a URL from the parent relation, it will not be accidentally overwritten by the accessor. Note that, there is no additional trickery outside Laravel, it’s just simple things like this what makes the framework beautiful.