L'helper once() in Laravel 12: memoizzazione per-request con WeakMap al posto di proprietà statiche e cache forzata

L'helper once() in Laravel 12: memoizzazione per-request con WeakMap al posto di proprietà statiche e cache forzata

In una piattaforma marketplace con migliaia di utenti attivi, un servizio di pricing calcolava le regole di sconto interrogando tre tabelle correlate - e veniva chiamato 8 volte nella stessa request da controller, middleware e view composer. La soluzione in Laravel 10 era una proprietà nullable $cachedRules con il pattern if (is_null($this->cachedRules)), duplicato in 12 servizi diversi. Il termine memoizzazione è stato coniato da Donald Michie nel 1968: memorizzare il risultato di una funzione per restituirlo direttamente quando gli stessi input si ripresentano. L'helper once() di Laravel formalizza questo pattern con un'API dichiarativa.

Come funziona l'helper once() in Laravel e cosa sostituisce?

once(), introdotto in Laravel 11 tramite PR #49744 di Nuno Maduro, è l'evoluzione nativa del pacchetto spatie/once - il cui v3 (novembre 2020) aveva già migrato dalla reflection-based scope detection alla WeakMap di PHP 8.0 (RFC di Nikita Popov, approvata unanimemente 25-0). La classe Illuminate\Support\Once mantiene un'istanza statica di WeakMap<object, array<string, mixed>>: le chiavi sono oggetti, e quando un oggetto viene garbage-collected le sue entry cachate vengono rimosse automaticamente - nessun memory leak in processi long-running.

Lo scoping della cache dipende dal contesto di chiamata: in un metodo d'istanza, la cache è per-oggetto (istanze diverse hanno cache separate); in un metodo statico, è per-classe dichiarante (attenzione: le classi figlie condividono la cache del parent); in contesto globale, è per call-site (file + riga). In Laravel Octane, il listener FlushOnce chiama Once::flush() su ogni OperationTerminated, garantendo che ogni request parta con cache vuota:

/* PRIMA (Laravel 10): proprietà nullable + boilerplate check */
class PricingService
{
    private ?array $cachedRules = null;

    public function getRules(): array
    {
        if (is_null($this->cachedRules)) {
            $this->cachedRules = DB::table('pricing_rules')
                ->join('tiers', 'pricing_rules.tier_id', '=', 'tiers.id')
                ->join('categories', 'pricing_rules.category_id', '=', 'categories.id')
                ->where('active', true)
                ->get()
                ->toArray();
        }
        return $this->cachedRules;
    }
}

/* DOPO (Laravel 12): once() elimina proprietà e boilerplate */
class PricingService
{
    public function getRules(): array
    {
        return once(fn () => DB::table('pricing_rules')
            ->join('tiers', 'pricing_rules.tier_id', '=', 'tiers.id')
            ->join('categories', 'pricing_rules.category_id', '=', 'categories.id')
            ->where('active', true)
            ->get()
            ->toArray()
        );
    }

    /* Ogni metodo che usa once() ha cache separata (call-site diverso) */
    public function getDiscountPercentage(int $tierId): float
    {
        return once(fn () => (float) DB::table('tiers')
            ->where('id', $tierId)
            ->value('discount_percentage')
        );
    }
}

Qual è la differenza tra once(), Cache::remember() e Cache::memo()?

I tre strumenti operano a livelli diversi. once() è in-memory, request-scoped, senza infrastruttura esterna - ideale per eliminare computazioni ridondanti nella stessa request. Cache::remember() è cross-request e persistente - richiede un cache driver (Redis, Memcached, file) e una strategia di invalidazione. Cache::memo(), introdotto in Laravel 12.9 (PR #55304 di Tim MacDonald), è un decorator che aggiunge un layer di memoizzazione in-memory sopra il cache driver: la prima chiamata va al backend, le successive nella stessa request restituiscono il valore in memoria. La motivazione documentata nella PR: "We put things in the cache because the cache is fast; that doesn't mean hitting the cache is free."

L'errore più frequente in codebase Laravel 9/10 è usare Cache::remember() con TTL di 1 secondo come sostituto di memoizzazione per-request - un anti-pattern che introduce overhead di rete (round-trip a Redis ~0.1-0.5ms), serializzazione/deserializzazione, e complessità nella generazione della chiave. once() risolve lo stesso problema senza uscire dal processo PHP:

/* Test: la closure di once() viene eseguita una sola volta */
public function test_pricing_rules_computed_once_per_request(): void
{
    DB::shouldReceive('table->join->join->where->get->toArray')
        ->once()
        ->andReturn([['id' => 1, 'discount' => 10]]);

    $service = new PricingService();

    $first = $service->getRules();
    $second = $service->getRules();
    $third = $service->getRules();

    $this->assertSame($first, $second);
    $this->assertSame($second, $third);
}

/* Test che once() si resetta tra test diversi (automatico in TestCase) */
public function test_different_service_instances_have_separate_cache(): void
{
    DB::shouldReceive('table->join->join->where->get->toArray')
        ->twice() /* Due istanze = due esecuzioni */
        ->andReturn([['id' => 1]], [['id' => 2]]);

    $service1 = new PricingService();
    $service2 = new PricingService();

    $this->assertNotSame(
        $service1->getRules(),
        $service2->getRules()
    );
}

La base TestCase di Laravel chiama Once::flush() automaticamente tra un test e l'altro - non serve cleanup manuale. Per forzare il reset durante un test, Once::flush() è disponibile come metodo pubblico.

Errori comuni nell'uso della memoizzazione in Laravel

Il primo errore è usare once() in metodi statici aspettandosi cache per-sottoclasse. La cache è associata alla classe dichiarante, non alla classe chiamante: se ParentService::getConfig() usa once(), ChildService::getConfig() restituirà lo stesso valore cachato - un comportamento documentato che sorprende chi eredita servizi.

Il secondo è memoizzare funzioni con side effect. once() implementa memoizzazione pura - è appropriato solo per funzioni referenzialmente trasparenti (stesso input → stesso output, nessun effetto collaterale). Memoizzare un metodo che scrive nel database o invia notifiche significa che la seconda chiamata non eseguirà l'operazione, causando bug silenziosi.

Il terzo è usare proprietà statiche invece di once() in ambienti Octane. Le proprietà statiche persistono tra request perché il processo worker non viene riavviato - se la prima request carica la configurazione per il tenant A, la seconda request (tenant B) riceve dati del tenant A. once() con FlushOnce risolve il problema, ma le proprietà statiche richiedono flush manuale nel listener RequestReceived.

Il quarto è usare once() per dati che servono cross-request. Un risultato API che cambia ogni ora deve stare in Cache::remember() con TTL appropriato, non in once() che lo ricalcola a ogni request. La regola: se il dato serve solo nella request corrente → once(). Se deve persistere tra request → Cache::remember() o Cache::memo().

La memoizzazione è un'ottimizzazione micro che diventa macro sotto carico: 8 chiamate ridondanti a 0.5ms ciascuna per 1000 request/secondo sono 4 secondi di CPU sprecata ogni secondo. L'ottimizzazione delle query Eloquent elimina le query N+1, once() elimina le chiamate ridondanti - sono complementari. Il Service Container con binding singleton e once() nei metodi copre tutti i casi di memoizzazione per-request senza boilerplate. Per conoscere il mio approccio all'ottimizzazione di applicativi Laravel, visita la mia pagina professionale. Se la tua codebase ha proprietà nullable duplicate per memoizzazione manuale o usa Cache::remember() per dati che servono solo nella request corrente, contattami per una consulenza dedicata - partiamo dal profiling delle chiamate ridondanti.

Ultima modifica: