Laravel Magazine
Memoize Expensive Calls in a Single Request with Laravel's once() Helper

Memoize Expensive Calls in a Single Request with Laravel's once() Helper

Eric Van Johnson ·

Some values get computed the same way, with the same result, multiple times in a single request — and nobody notices until a profiler points it out. A permission set gets built once per middleware check and once again in a view composer. A feature-flag lookup runs on every component that renders. None of these are wrong exactly, they're just wasteful, and the fix used to mean threading a property through a class or reaching for the cache.

Laravel's once() helper is a much lighter tool for this. It memoizes the return value of a closure for the lifetime of the current request (or, in a long-running process like an Octane worker or a queue worker, for the lifetime of that process) — call the same closure again from the same call site, and you get the cached result instead of re-running the logic.

The Motivating Example

Say PhpArchitect has a Team model with a method that computes an effective permission set — merging role-based permissions, plan-based feature entitlements, and any per-team overrides. It's not a single query, it's several, and it gets called from a policy, a Blade component, and an API resource, all within the same request.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Team extends Model
{
    public function effectivePermissions(): array
    {
        return once(function () {
            $rolePermissions = $this->roles()->with('permissions')->get()
                ->pluck('permissions')
                ->flatten()
                ->pluck('name');

            $planFeatures = $this->plan->features()->pluck('name');

            $overrides = $this->permissionOverrides()->pluck('name');

            return $rolePermissions
                ->merge($planFeatures)
                ->merge($overrides)
                ->unique()
                ->values()
                ->all();
        });
    }
}

Call $team->effectivePermissions() three times in the same request and the expensive work — three separate relationship queries and the merging logic — runs exactly once. Every subsequent call in that request returns the memoized array immediately.

Each Call Site Gets Its Own Memo

An important detail: once() keys its cache by the specific call site — the file and line where once() is invoked — combined with the object instance when it's called from inside a method. That means calling effectivePermissions() on two different Team instances in the same request runs the closure twice, once per instance, because each instance produces a distinct cache key even though the code is on the same line. Calling it twice on the same $team instance, though, only runs the closure once. If you instead call a plain memoized helper function (not tied to any object) from two different locations in your code, each location maintains its own independent cache, keyed purely by that call site:

function currentPlanLimits(): array
{
    return once(fn () => Plan::current()->limits()->get()->toArray());
}

Two different call sites invoking currentPlanLimits() — say, one in a controller and one in a middleware — each get their own memoized result the first time they're called, computed independently, because once() distinguishes call sites rather than sharing one global cache per function name.

The Octane and Queue Worker Caveat

This is the part that bites people. once() doesn't know anything about HTTP request boundaries — it just memoizes for as long as the PHP process lives. In traditional PHP-FPM, that's one request, so it's automatically safe. In Laravel Octane, a worker process stays alive across many requests, and in a queue worker it stays alive across many jobs.

That means a value memoized with once() on request one can leak into request two if you're not careful — particularly dangerous for anything user-specific or request-specific, like "the currently authenticated user's permission set" computed from data that could change between requests within the same worker.

A couple of ways to stay safe:

  • Avoid once() for anything tied to per-request or per-user state when running under Octane, and reach for something scoped to the request lifecycle instead.
  • If you do need memoization under Octane, clear it explicitly between requests using Octane's Octane::listen hooks (for example, tying into the RequestReceived or RequestHandled events) or by structuring the memoized value so it's naturally re-derived per request.
  • For queue workers, be similarly cautious with anything job-specific — a worker can process hundreds of jobs without restarting.

once() vs Cache::remember()

It's worth being explicit about how this differs from Cache::remember(), since they solve adjacent but different problems:

// Persists across requests, processes, and even servers —
// backed by Redis, database, file, etc.
$limits = Cache::remember('plan-limits:' . $plan->id, now()->addHour(), function () use ($plan) {
    return $plan->limits()->get()->toArray();
});

// Persists only in memory, only for this request/process,
// and disappears the moment it ends.
$limits = once(fn () => $plan->limits()->get()->toArray());

Cache::remember() is for values worth persisting across requests — an expensive computation that's the same for every user, or that doesn't change often. once() is for values that are only worth avoiding recomputation within a single request — the kind of duplicate work that happens because your code calls the same expensive thing from three unrelated places, not because the underlying data is stable over time.

Wrapping Up

once() is a small tool for a specific, common problem: the same expensive closure getting called more than once per request purely as a byproduct of how your code is organized. It's near-free to add and it requires no cache store, no keys, and no invalidation strategy — just be deliberate about it in Octane or queue-worker contexts, where "per request" quietly becomes "per however-long-this-worker-lives" if you're not paying attention.

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.