Lazy loading in PHP 8.4: ottimizzare l'inizializzazione di oggetti costosi

Lazy loading in PHP 8.4: ottimizzare l'inizializzazione di oggetti costosi

Quando un'applicazione PHP cresce oltre una certa soglia di complessità - tipicamente 50-80 servizi registrati nel container di dependency injection, con connessioni a database, client HTTP, code di messaggi e servizi di cache - il tempo di bootstrap dell'applicazione diventa un problema misurabile. Ogni richiesta HTTP che arriva al server deve istanziare il framework, risolvere le dipendenze nel container e costruire l'intero grafo dei servizi prima di poter eseguire una sola riga di logica di business. In un'applicazione Symfony di media complessità con 80 servizi registrati che ho ottimizzato per un cliente del settore servizi professionali, il profiling con Blackfire mostrava che il 38% del tempo di risposta - circa 45 millisecondi su 120 millisecondi totali - veniva speso nella fase di bootstrap, costruendo servizi che nella maggior parte delle richieste non venivano nemmeno utilizzati. Il servizio di generazione PDF veniva istanziato (con il suo client HTTP e le sue dipendenze) anche per le richieste che mostravano una semplice pagina HTML. Il servizio di invio email veniva costruito anche per le richieste che non inviavano nessuna email. Il client SOAP per il gestionale ERP veniva inizializzato anche per le richieste che non toccavano il gestionale.

PHP 8.4, rilasciato a novembre 2024, introduce una soluzione nativa a questo problema: i lazy objects. L'RFC approvata per i lazy objects aggiunge al linguaggio la capacità di creare oggetti il cui stato viene inizializzato solo al primo utilizzo effettivo, attraverso due strategie - ghost objects e proxy objects - implementate direttamente nel motore PHP senza bisogno di codice generato, proxy manuali o librerie esterne. Nel mio test sull'applicazione Symfony del cliente, sostituire i servizi pesanti con lazy ghost ha ridotto il tempo di bootstrap del 35% - da 45 millisecondi a 29 millisecondi - con otto righe di configurazione nel container Symfony. Per un'applicazione che serve 15.000 richieste al giorno, quei 16 millisecondi risparmiati per richiesta equivalgono a 240 secondi di tempo CPU liberato ogni giorno - risorse che il server può dedicare all'elaborazione delle richieste reali invece che alla costruzione di servizi inutilizzati.

Come funzionano i lazy objects nativi di PHP 8.4?

La documentazione ufficiale di PHP sui lazy objects descrive due strategie distinte. La prima è il lazy ghost: l'oggetto viene creato con la struttura della classe (le sue proprietà, i suoi metodi) ma il costruttore non viene eseguito. Quando il codice accede per la prima volta a una proprietà o chiama un metodo che accede allo stato interno, PHP invoca una funzione di inizializzazione che popola l'oggetto. La seconda è il lazy proxy: l'oggetto è un involucro trasparente che, alla prima interazione, chiama una factory function che restituisce l'oggetto reale, e da quel momento tutte le operazioni vengono delegate all'oggetto reale.

La differenza pratica è sottile ma importante per la scelta architetturale: il lazy ghost è l'oggetto (la stessa istanza viene inizializzata in-place), mentre il lazy proxy contiene l'oggetto (le operazioni vengono inoltrate a un'istanza separata). Per i servizi nel container di dependency injection, il lazy ghost è quasi sempre la scelta migliore perché non introduce un livello di indirezione aggiuntivo e mantiene il type checking perfettamente funzionante (l'oggetto è davvero un'istanza della classe dichiarata, non un proxy).

Ecco come si crea un lazy ghost per un servizio pesante:

// Esempio: lazy ghost per un servizio di generazione PDF
// Il costruttore viene eseguito SOLO quando si chiama un metodo

$reflector = new ReflectionClass(PdfGenerator::class);

$pdfGenerator = $reflector->newLazyGhost(function (PdfGenerator $instance) {
    // Questa funzione viene chiamata SOLO al primo utilizzo
    // Qui eseguiamo l'inizializzazione costosa
    $instance->__construct(
        httpClient: new HttpClient(['timeout' => 30]),
        templatePath: '/var/www/app/templates/pdf',
        fontCache: '/var/cache/fonts',
    );
});

// A questo punto $pdfGenerator esiste ma non è inizializzato
// Il costruttore non è stato eseguito, nessuna risorsa è stata allocata

// Solo quando lo usiamo effettivamente...
$pdf = $pdfGenerator->genera($datifattura);
// ...il costruttore viene eseguito e il servizio è pronto

Il punto chiave è che il lazy ghost è completamente trasparente al codice che lo utilizza. Il type hint PdfGenerator $pdfGenerator funziona perfettamente, instanceof PdfGenerator restituisce true, e qualsiasi metodo pubblico della classe è chiamabile - la differenza è solo nel quando il costruttore viene eseguito. Questo significa che puoi introdurre lazy loading nel tuo codice esistente senza modificare una sola riga dei consumatori dei servizi: cambi solo il modo in cui il container costruisce il servizio, non il modo in cui il servizio viene utilizzato.

La differenza tra lazy ghost e lazy proxy merita un approfondimento perché la scelta sbagliata può introdurre bug sottili. Il lazy ghost funziona modificando in-place l'istanza originale: quando l'inizializzatore viene chiamato, popola le proprietà dell'oggetto già esistente. Il vantaggio è che l'identità dell'oggetto non cambia - spl_object_id() restituisce lo stesso valore prima e dopo l'inizializzazione, il che significa che se hai conservato un riferimento all'oggetto lazy prima dell'inizializzazione, quel riferimento punta all'oggetto inizializzato dopo la prima interazione. Il lazy proxy, invece, crea un oggetto separato e delega tutte le operazioni: l'identità dell'oggetto proxy è diversa da quella dell'oggetto reale. Per i servizi nel container DI, questo è raramente un problema perché i servizi sono quasi sempre singleton e non vengono confrontati per identità. Ma se stai usando lazy objects per le entità di dominio (come fa Doctrine) o per oggetti che vengono usati come chiavi in array o in SplObjectStorage, la differenza di identità del proxy può causare comportamenti inattesi. La regola pratica che applico è: usa il lazy ghost come default per i servizi del container, e riserva il lazy proxy solo per i casi in cui il ghost non è applicabile - tipicamente quando l'inizializzatore deve restituire un'istanza di una sottoclasse diversa da quella dichiarata.

Integrazione con il container DI di Symfony e Laravel

Symfony utilizza già i lazy proxy da anni (dal componente symfony/proxy-manager-bridge con le classi generate da Ocramius) per i servizi dichiarati come lazy: true nella configurazione del container. Con PHP 8.4, Symfony può sostituire le classi proxy generate con i lazy ghost nativi del linguaggio - eliminando la dipendenza dal code generator e riducendo la complessità dell'infrastruttura di build.

La configurazione in Symfony è dichiarativa nel file services.yaml:

# config/services.yaml
services:
    App\Service\PdfGenerator:
        lazy: true
        arguments:
            $httpClient: '@http_client'
            $templatePath: '%kernel.project_dir%/templates/pdf'
            $fontCache: '%kernel.cache_dir%/fonts'

    App\Service\ErpSoapClient:
        lazy: true
        arguments:
            $wsdlUrl: '%env(ERP_WSDL_URL)%'
            $token: '%env(ERP_TOKEN)%'

    App\Service\EmailCampaignSender:
        lazy: true
        arguments:
            $mailer: '@mailer'
            $templateEngine: '@twig'

Con queste otto righe di configurazione (tre dichiarazioni lazy: true), i tre servizi più pesanti dell'applicazione del cliente vengono istanziati solo quando effettivamente utilizzati. Il container Symfony crea lazy ghost per ciascuno, e il costruttore - che include la connessione HTTP al client PDF, la lettura del WSDL per il client SOAP, e il pre-loading dei template email - viene eseguito solo alla prima chiamata effettiva del servizio.

In Laravel, l'integrazione richiede una configurazione leggermente diversa perché il container di Laravel non ha un flag lazy nativo. La soluzione è registrare i servizi con un binding che usa ReflectionClass::newLazyGhost():

// AppServiceProvider - registrazione lazy in Laravel
$this->app->singleton(PdfGenerator::class, function ($app) {
    $reflector = new ReflectionClass(PdfGenerator::class);

    return $reflector->newLazyGhost(function (PdfGenerator $instance) use ($app) {
        $instance->__construct(
            httpClient: $app->make(HttpClient::class),
            templatePath: resource_path('templates/pdf'),
            fontCache: storage_path('framework/cache/fonts'),
        );
    });
});

La differenza con il binding standard ($this->app->singleton(PdfGenerator::class, fn ($app) => new PdfGenerator(...))) è che nel binding lazy il costruttore non viene mai eseguito se il servizio non viene utilizzato nella richiesta corrente. Nel mio profilo professionale trovi l'esperienza che porto nell'ottimizzazione di applicazioni PHP ad alte prestazioni - e i lazy objects di PHP 8.4 sono uno degli strumenti più efficaci che ho aggiunto alla mia cassetta degli attrezzi nell'ultimo anno, perché producono miglioramenti misurabili con un investimento di tempo minimo.

L'impatto reale: misurazioni su un'applicazione in produzione

I numeri che presento vengono dall'applicazione Symfony del cliente, misurati con Blackfire prima e dopo l'introduzione dei lazy ghost sui tre servizi pesanti. L'ambiente è un VPS Hetzner CPX31 (4 vCPU, 8 GB RAM) con Debian 12, PHP 8.4.1, Symfony 7.2 e MySQL 8.0.

MetricaPrima (PHP 8.3, no lazy)Dopo (PHP 8.4, lazy ghost)Delta
Tempo bootstrap45 ms29 ms-35%
Memoria picco18,2 MB14,7 MB-19%
Tempo risposta mediano120 ms104 ms-13%
Oggetti istanziati per richiesta312247-21%

Il miglioramento più significativo è sul tempo di bootstrap - da 45 a 29 millisecondi - ma il dato sulla memoria è altrettanto rilevante: 3,5 MB in meno di picco RAM per richiesta significano che PHP-FPM può servire più richieste simultanee con lo stesso hardware, perché ogni worker consuma meno memoria. Su un server con 500 worker PHP-FPM, quei 3,5 MB in meno per worker liberano complessivamente 1,75 GB di RAM - risorse che prima erano allocate per servizi mai utilizzati nella maggior parte delle richieste.

Un aspetto che merita attenzione è la relazione con Doctrine ORM. Doctrine ha sempre utilizzato un proprio sistema di proxy per il lazy loading delle entità relazionate - i famosi "Proxy objects" generati nella directory proxy/ che ogni sviluppatore Symfony conosce. Con Doctrine ORM 3.4 (rilasciato nel 2025), il supporto per i lazy ghost nativi di PHP 8.4 è stato aggiunto come opzione di configurazione, eliminando la necessità di generare classi proxy e semplificando significativamente il processo di build e deployment. Ho verificato la compatibilità sull'applicazione del cliente: dopo l'aggiornamento a Doctrine 3.4 con lazy objects nativi abilitati, i test funzionali passano al 100% e le prestazioni di caricamento delle entità relazionate sono identiche o leggermente migliori rispetto ai proxy generati. Il beneficio operativo più importante è l'eliminazione del comando doctrine:generate-proxies dal processo di deploy - un passaggio che richiedeva 3-5 secondi, che falliva occasionalmente con errori di permessi sulla directory proxy/, e che rappresentava una sorgente di attrito nel CI/CD che ora semplicemente non esiste più. Per i team che gestiscono deployment frequenti (3-5 deploy al giorno, come nel caso del cliente), eliminare un passaggio dal pipeline di deploy non è un dettaglio - è meno complessità, meno punti di fallimento e meno tempo speso a debuggare build rotte.

La lezione operativa è che PHP 8.4 con i lazy objects non è un'ottimizzazione marginale per chi cerca di spremere l'ultimo millisecondo - è un cambiamento architetturale che permette di registrare decine di servizi nel container DI senza pagare il costo di inizializzazione per quelli che non servono nella richiesta corrente. Per le applicazioni Symfony e Laravel di media complessità che gestisco per i clienti PMI, il pattern è diventato standard: ogni servizio che ha un costruttore costoso (connessioni HTTP, lettura di file, client SOAP, pre-loading di configurazioni) viene dichiarato come lazy. Il costo di questa ottimizzazione è trascurabile - otto righe di YAML in Symfony o un binding leggermente diverso in Laravel - e il beneficio è misurabile e permanente. Se gestisci un'applicazione PHP che mostra tempi di risposta superiori ai 100 millisecondi anche su richieste semplici e il profiling indica un tempo di bootstrap elevato, i lazy objects di PHP 8.4 sono probabilmente il singolo intervento con il miglior rapporto costo/beneficio che puoi fare. Ho descritto un approccio complementare di refactoring per le applicazioni PHP legacy che ho modernizzato negli ultimi anni, dove l'ottimizzazione del container DI è una delle prime azioni nel piano di intervento. Se vuoi una valutazione dell'impatto dei lazy objects sulla tua applicazione specifica, contattami per una sessione di profiling mirato: in mezza giornata identifichiamo i servizi candidati, implementiamo la configurazione lazy e misuriamo il delta prestazionale con dati reali.

Ultima modifica: