Pattern: memoized() methods

By @samuel · 2021-10-14 18:24

Pattern: memoized() methods

Some users have pointed out that I had an error in the last Advanced Livewire article.

I was using this syntax for the memoized methods:

protected function category(): Category
{
    return $this->__category ??= Category::where(...)->first();
}

This does normally work, but it didn't work here due. Why? Because Livewire overrides __get() to support its own magic (such as computed properties which are pretty close to memoized methods).

One of PHP's limitations is that overriding __get() makes the ??= syntax stop working, so we need to do more precise checks instead:

protected function category(): Category
{
    return $this->__category = isset($this->__category)
        ? $this->__category
        : Category::where(...)->first();
}

This works. Note that the point of the __category property is that it:

  • caches the result of the Category::where(...) query
  • uses a property created at runtime (dynamic property is the term) which isn't shared with the frontend Livewire payload
  • uses a __ prefix to be distinguished from user-created properties. Sometimes we might have a category property with the category ID, and a category() method which turns that ID into a model instance

So we've made it work, but it's kinda ... ugly.

Which made me think that I could create some abstraction for this, like "cached models" or something.

But then I figured that this isn't just models, this can be used for any methods. Memoized methods are pretty common in some applications.

I always prefer them over for instance computed properties in Livewire, because a memoized method means creating one thing inside the class which has both the typehint and the logic.

Creating a computed property means we need a method with a kinda-long name (get<Foo>Property) and then a /** @property-read Foo $foo */ annotation to make our IDE understand what's going on when we try to access $this->foo.

So let's get back to memoized methods. How can we make them better?

I thought of this:

protected function category(): Category
{
    return $this->memoized(
        'category',
        fn () => Category::where(...)->first(),
    );
}

And the memoized() method would do a similar check to our isset() above, except it doesn't have to create properties like __category, it can just store everything in an array.

So that'd work, and it'd be pretty clean.

But it still seems a bit too noisy. We're using category for both the name and as the first argument in the memoized() call.

It doesn't look that bad here, but when you have several of these methods below each other, the noise becomes apparent.

So this made me think... if only we could read the method name somehow.

And then I remembered that Laravel uses this for relationships. So we can do this.

Let's see how Laravel does this. For example, imagine that we have a method like this:

// class Organization

public function administrator(): BelongsTo
{
    return $this->belongsTo(User::class);
}

How does Laravel know this relation will use the administrator_id column?

If we used user for both the method and the User::class part, it's simple. Laravel can just read the table information from the User model.

But here it knows to use the administrator id.

How? It checks the backtrace. We can pass the relation name as the fourth argument, but we don't have to. Laravel just reads the name of the method that called belongsTo(). Pretty neat.

So how can we replicate this? Very easily, actually.

protected array $__memoized = [];

protected function memoized(Closure $callback, string $key = null): mixed
{
    if (! $key) {
        [$current, $caller] = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2);

        $key = $caller['function'];
    }

    return $this->__memoized[$key] ??= $callback();
}

This fetches the function name of one step before the current method, and it uses it as the "cache key" (key in the $__memoized property).

Notice that we've swapped the argument order. We made the callback the first argument, because that's the only thing we need. (If we want, we can still pass the key as the second argument. But that's just for edge case support here. In reality we won't need this — probably ever.)

So now, with this as part of our class (in this specific example, the base Component class that we extend from all of our Livewire components), we can just write code like this:

protected function category(): Category
{
    return $this->memoized(fn () => Category::query()
        ->where('slug', $this->categorySlug)
        ->firstOrFail());
}

protected function reply(): Reply
{
    return $this->memoized(fn () => Reply::withTrashed()->findOrFail($this->replyId));
}

I like that. A lot. Much cleaner than computed properties for my use cases, and with identical behavior.