DEV Community

Cover image for Memoisation in Laravel Using the `once` Helper
Ash Allen
Ash Allen

Posted on • Originally published at ashallendesign.co.uk

Memoisation in Laravel Using the `once` Helper

Introduction

When building Laravel applications, there may be times when you need to use a value multiple times within the same request lifecycle, but calculating it is expensive or time-consuming. A possible solution optimise performance in these scenarios is "memoisation" (or "memoization").

Memoisation is the technique of temporarily storing the result of an expensive function call so that subsequent calls to that function return the cached result rather than recalculating it. If you've used caching before, memoisation is pretty similar, except that it generally lasts only for the duration of a single request, at least in the context of Laravel. Depending on the use case, it can help to reduce calls to databases, external APIs, cache stores, or methods that take a long time to run.

In this article, we're going to take a look at how to use Laravel's built-in once helper function to implement memoisation in your applications. We'll also explore the differences between using once inside functions and within object methods, including static methods. We'll then explore how Laravel Octane integrates with the once helper to ensure that memoised values are reset between requests. Finally, we'll briefly discuss other approaches to memoisation in Laravel.

Using the "once" Helper in Laravel

The once helper function in Laravel is really simple to use. It accepts a closure as an argument; the first time it's called, it returns the closure's result and stores it for future calls within the same request. On subsequent calls, it returns the cached result instead of executing the closure again. Unless the value is cleared, the cached result will persist for the duration of the request. After the request completes, the cached value is discarded. This means a value memoised with once in a request cannot be accessed in any other requests.

Let's take a look at a simple example. So we can focus on the memoisation aspect rather than any specific business logic, we'll just imagine our calculateExpensiveValue function performs an expensive calculation. We'll return the result of random_int to highlight that the value is only calculated once.

Imagine we have this function, which isn't using any form of memoisation:

function calculateExpensiveValue(): int
{
    // Imagine this function performs an expensive calculation. We'll
    // use the "random_int" function to simulate this.

    return random_int(min: 1, max: 100);
}

echo calculateExpensiveValue(); // e.g., outputs: 42
echo calculateExpensiveValue(); // e.g., outputs: 57
echo calculateExpensiveValue(); // e.g., outputs: 13
Enter fullscreen mode Exit fullscreen mode

As we can see, the output changes each time we call the function because it recalculates the value every time.

Let's take a look at what happens when we wrap the calculation in the once helper:

function calculateExpensiveValue(): int
{
    // Imagine this function performs an expensive calculation. We'll
    // use the "random_int" function to simulate this.

    return once(
        static fn (): int => random_int(min: 1, max: 100)
    );
}

echo calculateExpensiveValue(); // e.g., outputs: 42
echo calculateExpensiveValue(); // outputs: 42
echo calculateExpensiveValue(); // outputs: 42
Enter fullscreen mode Exit fullscreen mode

As we can see, the output remains the same for each call to calculateExpensiveValue because the result of the first call is cached and returned on subsequent calls.

Using "once" Inside Objects

There are a few "gotchas" to be aware of when using the once helper inside objects, particularly when it comes to instance methods versus static methods. So I wanted to quickly cover them here so you don't make the same mistakes as I've made in the past.

Using "once" in Methods

Let's stick with our previous example, but this time we'll put the calculateExpensiveValue function inside a class as a method:

declare(strict_types=1);

namespace App\Services;

final readonly class NumberService
{
    public function calculateExpensiveValue(): int
    {
        return once(
            static fn (): int => random_int(min: 1, max: 100)
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Now let's call this method a few times:

use App\Services\NumberService;

$numberService = new NumberService();

dump($numberService->calculateExpensiveValue()); // e.g., outputs: 42
dump($numberService->calculateExpensiveValue()); // outputs: 42
dump($numberService->calculateExpensiveValue()); // outputs: 42

$anotherNumberService = new NumberService();

dump($anotherNumberService->calculateExpensiveValue()); // e.g., outputs: 57
dump($anotherNumberService->calculateExpensiveValue()); // outputs: 57
dump($anotherNumberService->calculateExpensiveValue()); // outputs: 57
Enter fullscreen mode Exit fullscreen mode

As we can see, the output is consistent for each instance of the App\Services\NumberService class, but different between instances. This is because the once helper caches the result per object instance.

This has caught me out in the past because I assumed the cached value would be shared across all instances of the class, in a similar way to static properties. But as we can see, that's not the case.

Using "once" in Static Methods

However, when the method is static, it is not tied to a specific instance of the class. Instead, it's tied to the class itself, so the cached value is shared across all calls to the static method.

Let's look at what I mean by this. We'll update our calculateExpensiveValue method to be static:

declare(strict_types=1);

namespace App\Services;

final readonly class NumberService
{
    public static function calculateExpensiveValue(): int
    {
        return once(
            static fn (): int => random_int(min: 1, max: 100)
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

And we'll call the static method a few times:

use App\Services\NumberService;

$numberService = new NumberService();

dump($numberService::calculateExpensiveValue()); // e.g., outputs: 42
dump($numberService::calculateExpensiveValue()); // outputs: 42

dump(NumberService::calculateExpensiveValue()); // outputs: 42
dump(NumberService::calculateExpensiveValue()); // outputs: 42
Enter fullscreen mode Exit fullscreen mode

As we can see, the output remains consistent across all calls to the static method.

Using "once" with Laravel Octane

As I mentioned at the beginning of the article, memoised values created with the once helper are stored in memory and last only for the duration of a single request. This means requests can't access the memoised values of other requests, and when a request completes, the cached values are discarded.

However, you might be wondering how this works when using Laravel Octane, which keeps your application in memory between requests to improve performance. In this case, if the memoised values were not cleared between requests, they would persist across requests, which is not the intended behaviour.

Laravel Octane handles this in a really cool way by providing an event listener that flushes the memoised values at the end of each request lifecycle. This ensures that each request starts with a clean slate, and any memoised values are recalculated as needed. Let's take a look at how this is implemented.

Octane's config file (located at config/octane.php) includes a listeners array where various event listeners are registered. The Laravel\Octane\Listeners\FlushOnce listener is registered to listen for the Laravel\Octane\Contracts\OperationTerminated event like so:

use Laravel\Octane\Contracts\OperationTerminated;
use Laravel\Octane\Listeners\FlushOnce;

return [

    // ...

    /*
    |--------------------------------------------------------------------------
    | Octane Listeners
    |--------------------------------------------------------------------------
    |
    | All of the event listeners for Octane's events are defined below. These
    | listeners are responsible for resetting your application's state for
    | the next request. You may even add your own listeners to the list.
    |
    */

    'listeners' => [
        // ...

        OperationTerminated::class => [
            FlushOnce::class,
            // ...
        ],

        // ...
    ],

    // ...

];
Enter fullscreen mode Exit fullscreen mode

So when the Laravel\Octane\Contracts\OperationTerminated event is fired at the end of each request, the Laravel\Octane\Listeners\FlushOnce listener is executed. This listener calls the Illuminate\Support\Once::flush method to clear all memoised values:

namespace Laravel\Octane\Listeners;

use Illuminate\Support\Once;

class FlushOnce
{
    /**
     * Handle the event.
     *
     * @param  mixed  $event
     */
    public function handle($event): void
    {
        if (class_exists(Once::class)) {
            Once::flush();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The flush method achieves this by setting the static $instance property of the Illuminate\Support\Once class to null, effectively discarding all cached values:

namespace Illuminate\Support;

use WeakMap;

class Once
{
    /**
     * The current globally used instance.
     *
     * @var static|null
     */
    protected static ?self $instance = null;

    // ...

    /**
     * Create a new once instance.
     *
     * @param  \WeakMap<object, array<string, mixed>>  $values
     */
    protected function __construct(protected WeakMap $values)
    {
        //
    }

    /**
     * Create a new once instance.
     *
     * @return static
     */
    public static function instance()
    {
        return static::$instance ??= new static(new WeakMap);
    }

    // ...

    /**
     * Flush the once instance.
     *
     * @return void
     */
    public static function flush()
    {
        static::$instance = null;
    }
}
Enter fullscreen mode Exit fullscreen mode

Although this is all handled automatically by Laravel Octane, it's good to understand how it works under the hood so you can be confident that your memoised values are being managed correctly. Reading vendor code like this is also a great way to learn more about how things work internally.

Other Approaches to Memoisation in Laravel

Although the once helper is a great built-in way to handle memoisation in Laravel, there are other approaches you may want to take into consideration depending on your specific use case.

The techniques I'll mention here might not be strictly considered "memoisation". But they can achieve similar results by holding a value that can be reused and improving performance.

The "Cache::memo" Method

Laravel ships with a handy memo method which can be used when interacting with the cache. It holds the value retrieved from the cache in memory so that subsequent calls to retrieve the same cache key within the same request do not hit the cache store again. Here's an example of how to use it:

use Illuminate\Support\Facades\Cache;

$value = Cache::memo()->get('key'); // First call hits the cache store
$value = Cache::memo()->get('key'); // Subsequent calls return the value from memory
Enter fullscreen mode Exit fullscreen mode

If we assume we're using Redis as a cache store, the first get call would hit the Redis server to retrieve the value associated with the key. However, the second call would return the value stored in memory by the memo method, avoiding an additional round-trip to the Redis server. Although fetching values from Redis is usually fast, avoiding unnecessary network calls is always a good idea, especially in high-traffic applications.

The "array" Cache Store

Another approach you can use, which I don't see used too often, is the array cache store. This driver stores cached values in an in-memory array for the duration of the request, allowing them to be reused multiple times within the same request without hitting an external cache store.

I've actually written about it before in a past blog post, so I won't go into too much detail here. But here's a quick example of how to use it:

use Illuminate\Support\Facades\Cache;

Cache::store('array')->put('key', 'value', 60); // Store value in array cache

$value = Cache::store('array')->get('key');
$value = Cache::store('array')->get('key');
Enter fullscreen mode Exit fullscreen mode

In all fairness, I don't generally have a use case for this approach in my applications, but it's always worth knowing about the different techniques available to you. Sometimes knowing about these things can spark ideas for optimisations you might not have thought of otherwise.

However, it's worth noting that the Laravel documentation specifically states that the array driver "provides a convenient cache backend for your automated tests". This suggests to me that it might not be the best choice for production applications, so use with caution.

Properties and Static Properties

Another approach to memoisation is to simply use class properties or static properties to store values that you want to reuse within the same request. This is a straightforward approach that can work well in many scenarios. In essence, this is all the once helper does under the hood, except with additional management to ensure the values are scoped correctly and so on.

For example, you could use a class property to hold a value that is expensive to calculate:

declare(strict_types=1);

namespace App\Services;

final class NumberService
{
    private ?int $expensiveValue = null;

    public function calculateExpensiveValue(): int
    {
        // If the value has already been calculated, return it.
        // Otherwise, calculate it and store it in the property.
        return $this->expensiveValue ??= random_int(min: 1, max: 100);
    }
}

$numberService = new NumberService();

echo $numberService->calculateExpensiveValue(); // e.g., outputs: 42
echo $numberService->calculateExpensiveValue(); // outputs: 42
Enter fullscreen mode Exit fullscreen mode

Although this seems like an obvious solution to just hold the value in a property, sometimes we can overlook the simple solutions. Just remember, if you go down this route, be mindful of the scope of the property (i.e., instance vs static) and how that affects the lifetime of the cached value. You have to remember that static properties can be shared across requests when using Laravel Octane, so you'll need to manage them accordingly and reset the value at the end of each request if necessary.

Conclusion

Hopefully, this article has given you a good understanding of how to use the once helper in Laravel for memoisation, as well as the nuances of using it within object methods. We've also explored how Laravel Octane integrates with the once helper to ensure that memoised values are reset between requests.

If you enjoyed reading this post, you might be interested in checking out my 220+ page ebook "Battle Ready Laravel" which covers similar topics in more depth.

Or, you might want to check out my other 440+ page ebook "Consuming APIs in Laravel" which teaches you how to use Laravel to consume APIs from other services.

If you're interested in getting updated each time I publish a new post, feel free to sign up for my newsletter.

Keep on building awesome stuff! 🚀

Top comments (0)