Nello sviluppo di applicazioni web con Laravel, ci imbattiamo spesso in situazioni in cui un determinato valore, risultato di un calcolo costoso o del recupero di dati, è necessario più volte durante il ciclo di vita di una singola richiesta HTTP o l'esecuzione di un singolo comando Artisan. Ripetere queste operazioni onerose inutilmente può portare a un degrado delle performance e a uno spreco di risorse. Per ovviare a ciò, si ricorre alla memoizzazione: calcolare il valore una volta e conservarlo in memoria per riutilizzarlo nelle chiamate successive all'interno dello stesso ciclo.
Nelle applicazioni Laravel 9 o Laravel 10 più datate, o in quelle dove non si è ancora adottata una soluzione framework-native più recente, la memoizzazione per-richiesta veniva spesso implementata con soluzioni custom: proprietà di istanza in servizi singleton, proprietà statiche, o talvolta un uso un po' forzato del cache store di Laravel. Con l'introduzione dell'helper once()
in Laravel 11 (e quindi parte integrante di un moderno stack Laravel 12), il framework offre ora un modo estremamente elegante, conciso e sicuro per gestire questo pattern.
Questo articolo tecnico ti guiderà nel refactoring di queste implementazioni di memoizzazione custom, mostrando come l'helper once()
possa semplificare il tuo codice, renderlo più leggibile e robusto, a tutto vantaggio delle applicazioni della tua impresa.
Se vuoi approfondire, continua a leggere. Se hai una domanda specifica a riguardo di questo articolo, contattami per una consulenza dedicata. Dai anche un'occhiata al mio profilo per capire come posso aiutare concretamente la tua azienda o startup a crescere e a modernizzarsi.
Memoizzazione "custom" in Laravel 9/10: tecniche comuni e i loro limiti
Vediamo alcuni approcci che potresti trovare (o aver implementato) in un'applicazione Laravel 9/10 per ottenere la memoizzazione per-richiesta.
Approccio 1: Proprietà d'istanza in un servizio
Una tecnica comune è utilizzare una proprietà all'interno di un servizio (spesso registrato come singleton nel service container) per "ricordare" un valore calcolato.
// Esempio Servizio L9/L10 con memoizzazione in proprietà d'istanza
namespace App\Services\Legacy;
use App\Models\User; // Assumiamo un model User
use Illuminate\Support\Facades\DB; // Per un esempio di query
class CompanyReportService
{
protected ?User $currentUser = null;
protected ?array $cachedUserReportData = null;
// Il servizio potrebbe essere un singleton o istanziato per richiesta
public function __construct(User $currentUser = null)
{
$this->currentUser = $currentUser ?? auth()->user();
}
/**
* Recupera dati di reportistica costosi per l'utente.
*/
public function getUserReportData(): array
{
if (is_null($this->currentUser)) {
return ['error' => 'Utente non specificato'];
}
if (is_null($this->cachedUserReportData)) {
logger()->info("CompanyReportService: Calcolo dati report per utente ID {$this->currentUser->id}");
// Simula un calcolo costoso (es. aggregazione da più tabelle)
sleep(1); // Simula latenza
$this->cachedUserReportData = DB::table('sales')
->where('user_id', $this->currentUser->id)
->join('products', 'sales.product_id', '=', 'products.id')
->selectRaw('products.category, SUM(sales.amount) as total_sales')
->groupBy('products.category')
->orderByDesc('total_sales')
->get()
->toArray();
}
return $this->cachedUserReportData;
}
}
// Utilizzo nel controller:
// $reportService = app(CompanyReportService::class); // Risolto come singleton o nuova istanza
// $data1 = $reportService->getUserReportData(); // Calcola la prima volta
// $data2 = $reportService->getUserReportData(); // Usa il valore cachato $this->cachedUserReportData
- Limiti:
- Funziona bene se il servizio è un singleton (una sola istanza per richiesta). Se viene creata una nuova istanza del servizio più volte nella stessa richiesta, la memoizzazione non ha effetto tra le istanze.
- La logica di "check-if-null-then-calculate" è boilerplate ripetuto.
- La proprietà
cachedUserReportData
è specifica per questo calcolo. Se il servizio ha più metodi che necessitano di memoizzazione, servono più proprietà di cache.
Approccio 2: Proprietà statiche in una classe
Per valori che non dipendono dallo stato di un'istanza specifica ma devono essere calcolati una sola volta per richiesta a livello globale.
// Esempio con proprietà statica (L9/L10)
namespace App\Services\Legacy;
use Illuminate\Support\Facades\Http;
class ExternalApiServiceConfig
{
protected static ?array $serviceEndpoints = null;
protected static int $callCount = 0; // Solo per debug
public static function getEndpoints(): array
{
if (is_null(self::$serviceEndpoints)) {
self::$callCount++;
logger()->info("ExternalApiServiceConfig: Caricamento endpoint da servizio esterno (chiamata #" . self::$callCount . ")");
// Simula una chiamata HTTP costosa per ottenere la configurazione degli endpoint
sleep(1);
// $response = Http::get('https://config.thirdparty.com/endpoints');
// self::$serviceEndpoints = $response->json();
self::$serviceEndpoints = [
'users' => 'https://api.thirdparty.com/users',
'products' => 'https://api.thirdparty.com/products',
];
}
return self::$serviceEndpoints;
}
// Necessario per il testing e per ambienti long-running come Octane
public static function flushStaticCache(): void
{
self::$serviceEndpoints = null;
self::$callCount = 0; // Resetta il contatore
}
}
// Utilizzo:
// $endpoints1 = ExternalApiServiceConfig::getEndpoints(); // Chiama API e cacha staticamente
// $endpoints2 = ExternalApiServiceConfig::getEndpoints(); // Usa valore cachato staticamente
- Limiti:
- Stato globale: le proprietà statiche mantengono il loro stato per tutta la durata del processo PHP. In ambienti serverless o tradizionali PHP-FPM, questo di solito equivale a una singola richiesta. Ma in ambienti long-running come Laravel Octane, lo stato statico persiste tra le richieste se non esplicitamente resettato (da qui la necessità di un metodo come
flushStaticCache
). Questo può portare a data leak tra richieste o comportamenti inattesi. - Testabilità: mockare o resettare lo stato statico nei test può essere più complicato.
- Stato globale: le proprietà statiche mantengono il loro stato per tutta la durata del processo PHP. In ambienti serverless o tradizionali PHP-FPM, questo di solito equivale a una singola richiesta. Ma in ambienti long-running come Laravel Octane, lo stato statico persiste tra le richieste se non esplicitamente resettato (da qui la necessità di un metodo come
Approccio 3: Uso del Cache Store di Laravel (in modo improprio per memoizzazione per-richiesta)
Qualcuno potrebbe pensare di usare il cache store di Laravel (es. Redis, Memcached, file) con una durata molto breve (es. 1 secondo) per simulare una cache per-richiesta.
// Esempio di "abuso" del Cache Store (L9/L10)
use Illuminate\Support\Facades\Cache;
use App\Models\User;
// ...
public function getUserPermissions(User $user): array
{
$cacheKey = "user_permissions_{$user->id}_" . request()->fingerprint(); // Chiave unica per utente e richiesta
return Cache::remember($cacheKey, 1, function () use ($user) { // TTL di 1 secondo
logger()->info("Cache Store: Calcolo permessi per utente {$user->id}");
sleep(1); // Simula calcolo costoso
return $user->load('roles.permissions')->roles->flatMap->permissions->pluck('name')->unique()->toArray();
});
}
- Limiti:
- Overhead inutile: implica una chiamata al driver di cache esterno (es. rete per Redis/Memcached, I/O disco per
file
), serializzazione/deserializzazione dei dati. Tutto questo per un valore che serve solo per la durata della richiesta corrente. È molto inefficiente. - Complessità della chiave: generare una chiave di cache che sia unica per la richiesta e per i parametri specifici può essere complicato.
- Rischio di collisioni o dati obsoleti: se il TTL non è gestito perfettamente o se la
fingerprint()
della richiesta non è sufficientemente univoca in tutti i contesti.
- Overhead inutile: implica una chiamata al driver di cache esterno (es. rete per Redis/Memcached, I/O disco per
Laravel 11+ e l'helper once()
: eleganza e semplicità per la memoizzazione per-richiesta
Laravel 11 ha introdotto l'helper globale once(callable $callback): mixed
. Questa semplice funzione risolve elegantemente il problema della memoizzazione per-richiesta.
Come funziona: L'helper once()
garantisce che la callback fornita venga eseguita una sola volta durante il ciclo di vita di una singola richiesta HTTP o l'esecuzione di un singolo comando Artisan. Il risultato della prima esecuzione viene cachato in memoria (associato all'oggetto o al contesto chiamante) e restituito a tutte le chiamate successive a once()
con la stessa callback (nello stesso contesto) all'interno di quella richiesta/comando.
- Thread-safe (in contesti come Octane con Swoole/RoadRunner): Laravel gestisce internamente la cache di
once()
in modo che sia sicura anche con le fibre PHP (se PHP >= 8.1 e l'estensionefibers
è attiva) o con meccanismi di lock per versioni precedenti, assicurando che la callback sia effettivamente eseguita una sola volta anche con richieste concorrenti gestite dallo stesso processo worker. - Reset automatico tra le richieste: in ambienti long-running come Octane, la cache di
once()
viene automaticamente resettata tra una richiesta e l'altra, evitando i problemi delle proprietà statiche non gestite. - Legata al contesto: se usata all'interno di un metodo d'oggetto (non statico), la cache è specifica per quell'istanza dell'oggetto e per il metodo da cui
once()
è chiamato. Se usata in una closure statica o globalmente, la cache è legata alla closure stessa.
Guida pratica al refactoring con once()
Vediamo come refactorare gli esempi precedenti.
Refactoring dell'Approccio 1 (Proprietà d'istanza in un servizio):
// Esempio Servizio L11/L12 con helper once()
namespace App\Services\Modern;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class CompanyReportService
{
protected User $currentUser;
public function __construct(User $currentUser = null)
{
$this->currentUser = $currentUser ?? auth()->user();
}
protected function loadUserReportDataOnce(): array
{
// La closure passata a once() viene eseguita solo la prima volta
// che questo metodo (loadUserReportDataOnce) viene chiamato SU QUESTA ISTANZA ($this).
// Laravel usa spl_object_id($this) e il nome del file/linea del chiamante
// per creare una chiave univoca per la cache di once().
return once(function () {
if (is_null($this->currentUser)) {
return ['error' => 'Utente non specificato per il report (once)'];
}
logger()->info("CompanyReportService (once): Calcolo dati report per utente ID {$this->currentUser->id}");
sleep(1); // Simula latenza
return DB::table('sales')
->where('user_id', $this->currentUser->id)
->join('products', 'sales.product_id', '=', 'products.id')
->selectRaw('products.category, SUM(sales.amount) as total_sales')
->groupBy('products.category')
->orderByDesc('total_sales')
->get()
->toArray();
});
}
public function getUserReportData(): array
{
return $this->loadUserReportDataOnce();
}
// Se avessi un altro metodo che necessita di un altro valore memoizzato:
public function getAggregatedCompanyStats(): array
{
return once(function () {
logger()->info("CompanyReportService (once): Calcolo statistiche aggregate azienda...");
// ... altro calcolo costoso ...
return ['total_revenue' => 100000, 'active_users' => 500];
});
// Nota: la cache per questo `once()` è separata da quella di `loadUserReportDataOnce()`
// perché sono in contesti di chiamata diversi (metodi diversi o linee diverse).
}
}
// Utilizzo:
// $reportService = app(CompanyReportService::class); // Immagina sia risolto con l'utente corrente
// $data1 = $reportService->getUserReportData(); // Calcola e cacha
// $data2 = $reportService->getUserReportData(); // Usa valore cachato
// $stats = $reportService->getAggregatedCompanyStats(); // Altro calcolo, cachato separatamente
Il codice è più pulito: la logica di "check-if-null" è sparita, delegata a once()
.
Refactoring dell'Approccio 2 (Proprietà statiche): L'helper once()
è perfetto per sostituire l'uso di proprietà statiche per la memoizzazione per-richiesta, eliminando i rischi in ambienti long-running.
// Esempio con helper once() per sostituire proprietà statica (L11/L12)
namespace App\Services\Modern;
class ExternalApiServiceConfig
{
public static function getEndpoints(): array
{
// La cache di once() qui è legata a questa specifica closure e al suo contesto statico.
// Verrà resettata tra le richieste HTTP in ambienti come Octane.
return once(function () {
logger()->info("ExternalApiServiceConfig (once): Caricamento endpoint da servizio esterno...");
sleep(1); // Simula chiamata HTTP costosa
return [
'users' => 'https://api.thirdparty.com/users',
'products' => 'https://api.thirdparty.com/products',
'version' => 'v2.1'
];
});
}
public static function getEndpointFor(string $serviceKey): ?string
{
$endpoints = self::getEndpoints(); // Questa chiamata userà il valore memoizzato se già calcolato
return $endpoints[$serviceKey] ?? null;
}
}
// Utilizzo:
// $userEndpoint = ExternalApiServiceConfig::getEndpointFor('users'); // Carica e cacha endpoints
// $productEndpoint = ExternalApiServiceConfig::getEndpointFor('products'); // Usa endpoints cachati
Non c'è più bisogno di un metodo flushStaticCache()
.
Refactoring dell'Approccio 3 (Cache Store di Laravel): Questo è semplice: sostituisci la chiamata a Cache::remember()
con once()
.
// Sostituzione dell'uso improprio di Cache::remember (L11/L12)
use App\Models\User;
// ...
public function getUserPermissions(User $user): array
{
// La chiave di cache interna di once() sarà basata sull'oggetto $user (se $this è $user)
// o sul contesto chiamante. Se questo metodo è in un servizio e $user è un parametro,
// la cache di once() potrebbe non distinguere tra utenti diversi se la closure non li usa
// per variare il suo "contesto" (es. se la closure è identica).
// Per essere sicuri che sia per utente, la chiamata a once() deve avvenire in un contesto
// che includa l'utente, o la closure stessa deve essere parametrizzata.
// Un modo è chiamare once() su un metodo dell'oggetto User stesso, o in un servizio
// che riceve l'utente e lo usa nella closure di once().
// Esempio più corretto se questo metodo è in un servizio che non ha $user come proprietà:
// return once(fn() => $this->calculateUserPermissions($user));
// Oppure, se $user è una proprietà del servizio:
return once(function () use ($user) { // `use ($user)` rende la closure unica per questo utente
logger()->info("Helper once(): Calcolo permessi per utente {$user->id}");
sleep(1);
return $user->load('roles.permissions')->roles->flatMap->permissions->pluck('name')->unique()->toArray();
});
// Attenzione: se $user cambia ma la *definizione* della closure (stesso file/linea)
// non cambia, e la closure non cattura $user nel suo scope in modo che `once` possa
// distinguerla, potrebbe restituire il valore della prima chiamata.
// L'implementazione di `once` (Once::class) usa un array statico
// la cui chiave è generata da `debug_backtrace`. Se la chiamata avviene
// sempre dalla stessa linea di codice, la cache potrebbe essere la stessa.
// Per ovviare, si può usare un oggetto come primo argomento di `once`:
// return once($user, fn() => $this->calculateUserPermissions($user));
// Ma la documentazione suggerisce che `once` è legato all'oggetto chiamante,
// o alla specifica istanza della closure (che qui cambia con `use ($user)`).
// Laravel 11+ once helper (global) uses an instance of Illuminate\Support\Once per application instance.
// The cache key is based on the call site (file + line + object hash if in method, or closure hash).
// Closures with different `use` variables have different hashes.
}
Nota sulla Cache Key di once()
: L'helper once()
è abbastanza intelligente. Se lo chiami all'interno di un metodo di un oggetto, il valore cachato è associato a quell'istanza specifica dell'oggetto e al punto di chiamata. Se chiami once()
con una closure che use
variabili diverse, queste closure avranno hash diversi, portando a cache separate. Questo lo rende molto flessibile e sicuro per la maggior parte dei casi d'uso per-richiesta.
Uso di once()
in altri contesti
Model Eloquent (Attributi Derivati Costosi):
// app/Models/Order.php namespace App\Models; use Illuminate\Database\Eloquent\Model; class Order extends Model { // ... public function getTotalAmountWithTaxesAttribute(): float { return once(function () { logger("Order {$this->id}: Calcolo importo totale con tasse..."); // Simula un calcolo costoso, magari con più chiamate a relazioni sleep(1); $subtotal = $this->items()->sum('price'); $taxRate = $this->region->tax_rate; // Altra chiamata a relazione return $subtotal * (1 + $taxRate); }); } }
Comandi Artisan: Per operazioni che potrebbero essere chiamate più volte durante una singola esecuzione del comando.
Testing del codice che usa once()
Il comportamento di once()
si integra bene con il ciclo di vita dei test di Laravel. Il valore cachato da once()
persiste per la durata di un singolo test. Per verificare che la callback di once()
sia stata eseguita (o non eseguita, indicando che è stato usato un valore cachato), puoi:
- Aggiungere un logging all'interno della callback e usare
Log::spy()
. - Usare
Mockery::spy()
su un oggetto che viene chiamato solo all'interno della callback dionce()
.
// tests/Unit/Services/ModernCompanyReportServiceTest.php
namespace Tests\Unit\Services\Modern;
use App\Services\Modern\CompanyReportService;
use App\Models\User;
use Illuminate\Support\Facades\DB; // Per il mocking delle query se necessario
use Illuminate\Support\Facades\Log;
use Illuminate\Foundation\Testing\RefreshDatabase; // Se il servizio interagisce con il DB
use Tests\TestCase;
class ModernCompanyReportServiceTest extends TestCase
{
use RefreshDatabase;
public function test_report_data_is_calculated_only_once_per_service_instance_per_request(): void
{
Log::spy(); // Spia il facade Log
$user = User::factory()->create();
$service = new CompanyReportService($user); // Crea istanza del servizio
// Simula che la query DB ritorni qualcosa
DB::shouldReceive('table->where->join->selectRaw->groupBy->orderByDesc->get->toArray')
->once() // Assicurati che la query DB sia fatta una sola volta
->andReturn([['category' => 'Test', 'total_sales' => 100]]);
// Prima chiamata: la closure di once() dovrebbe essere eseguita
$data1 = $service->getUserReportData();
$this->assertNotEmpty($data1);
// Seconda chiamata: la closure di once() NON dovrebbe essere eseguita di nuovo
$data2 = $service->getUserReportData();
$this->assertEquals($data1, $data2);
// Terza chiamata con un altro metodo che usa once()
$stats = $service->getAggregatedCompanyStats();
$this->assertArrayHasKey('total_revenue', $stats);
// Verifica che il logger specifico (dentro la closure di once() per getUserReportData)
// sia stato chiamato esattamente una volta.
Log::shouldHaveReceived('info')
->with(Mockery::pattern("/CompanyReportService \(once\): Calcolo dati report per utente ID {$user->id}/"))
->once();
// E che il logger per getAggregatedCompanyStats sia stato chiamato una volta
Log::shouldHaveReceived('info')
->with(Mockery::pattern("/CompanyReportService \(once\): Calcolo statistiche aggregate azienda\.\.\./"))
->once();
}
}
Benefici del refactoring con once()
per la tua impresa
Adottare l'helper once()
per la memoizzazione per-richiesta porta a:
- Codice Più Pulito e Leggibile: La logica di "esegui solo una volta" è incapsulata e dichiarativa.
- Minore Rischio di Errori: Meno codice boilerplate custom significa meno superficie per bug nella logica di caching manuale.
- Performance Migliorate: Previene in modo efficiente l'esecuzione ripetuta di calcoli o query costose all'interno di una singola richiesta.
- Testabilità Semplificata: Il comportamento è predicibile e si integra bene con gli strumenti di testing di Laravel.
- Gestione Corretta in Ambienti Long-Running (Octane):
once()
è progettato per funzionare correttamente in questi contesti, resettando automaticamente la sua cache tra le richieste, a differenza delle proprietà statiche non gestite.
Il ruolo del programmatore Laravel esperto
Identificare le opportunità per utilizzare once()
e refactorare codice legacy che implementa memoizzazione custom in modo meno efficiente o sicuro è un compito in cui l'esperienza fa la differenza. Come sviluppatore laravel con un occhio attento all'ottimizzazione e alla pulizia del codice (vedi il mio approccio su Chi Sono), posso aiutare la tua impresa a:
- Scansionare la base di codice alla ricerca di pattern di memoizzazione custom.
- Implementare il refactoring verso
once()
in modo sicuro. - Assicurare che i test coprano il nuovo comportamento.
L'helper once()
è un esempio di come Laravel continui a evolvere, offrendo strumenti semplici ma potenti per risolvere problemi comuni in modo elegante. È un piccolo cambiamento che può portare grandi benefici alla qualità e all'efficienza del codice delle applicazioni del tuo business.
Se vuoi ottimizzare la tua applicazione Laravel 9/10 e prepararla per un futuro con Laravel 12, sfruttando queste utili funzionalità per un codice più performante e manutenibile, contattami per una consulenza e un piano di modernizzazione.
Ultima modifica: Venerdì 28 Febbraio 2025, alle 13:05