Riepilogo post nella categoria Laravel 11

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.

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.

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'estensione fibers è 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 di once().
// 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

Il sistema di eventi e listener di Laravel è una potente implementazione del Observer pattern, che permette di disaccoppiare diverse parti di un'applicazione. Quando un determinato evento si verifica nel tuo business (ad esempio, un utente si registra, un ordine viene piazzato, un file viene caricato), uno o più listener possono reagire a quell'evento per eseguire azioni specifiche, come inviare email, aggiornare statistiche, o accodare job. Questo approccio promuove un codice più pulito, modulare e manutenibile.

Tuttavia, nelle applicazioni Laravel 9 o Laravel 10 più datate o di grandi dimensioni, la registrazione manuale di tutti gli eventi e dei loro listener all'interno della proprietà $listen dell'EventServiceProvider può diventare un compito oneroso e portare a file di configurazione molto estesi. Fortunatamente, con l'evoluzione del framework e l'adozione delle funzionalità più recenti di PHP (come gli attributi di PHP 8), Laravel (specialmente a partire dalla versione 11 e in prospettiva Laravel 12) offre modi più eleganti e automatizzati per gestire questa registrazione: l'event discovery e l'attributo #[AsEventListener].

Questo articolo tecnico ti guiderà attraverso il processo di refactoring della gestione eventi della tua impresa, passando dall'approccio tradizionale basato sull'EventServiceProvider a queste tecniche moderne, con l'obiettivo di migliorare la leggibilità del codice, la Developer Experience (DX) e la manutenibilità generale della tua applicazione Laravel.

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.

Gestione eventi "classica" in Laravel 9/10: EventServiceProvider e l'array $listen

Ricordiamo brevemente come eventi e listener venivano tipicamente definiti e registrati.

1. Definizione di un Evento

Un evento è una semplice classe PHP, spesso un Data Transfer Object (DTO), che contiene informazioni sull'accaduto. Di solito utilizza il trait Illuminate\Foundation\Events\Dispatchable per facilitarne l'invio e Illuminate\Queue\SerializesModels se l'evento (o i suoi dati) devono essere serializzati per i listener in coda.

// app/Events/OrderPlaced.php (tipico in L9/L10)
namespace App\Events;

use App\Models\Order; // Il tuo model Order
use Illuminate\Broadcasting\InteractsWithSockets; // Opzionale, per broadcasting
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderPlaced
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public Order $order;

    /**
     * Crea una nuova istanza dell'evento.
     */
    public function __construct(Order $order)
    {
        $this->order = $order;
    }
}

2. Creazione di un Listener

Un listener è una classe che contiene un metodo handle (o un altro metodo specificato) che riceve l'istanza dell'evento e ne esegue la logica associata. Può opzionalmente implementare l'interfaccia Illuminate\Contracts\Queue\ShouldQueue se la sua esecuzione deve essere asincrona.

// app/Listeners/SendOrderConfirmationEmail.php (tipico in L9/L10)
namespace App\Listeners;

use App\Events\OrderPlaced;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Mail; // Esempio
use App\Mail\OrderConfirmationMailable; // Ipotetico Mailable

class SendOrderConfirmationEmail implements ShouldQueue // In coda
{
    use InteractsWithQueue;

    /**
     * Gestisce l'evento.
     */
    public function handle(OrderPlaced $event): void
    {
        // Esempio: invia un'email di conferma
        // Mail::to($event->order->customer_email)
        //     ->send(new OrderConfirmationMailable($event->order));
        logger()->info("Listener SendOrderConfirmationEmail: Email di conferma inviata per ordine #{$event->order->id}");
    }
}
// app/Listeners/UpdateInventoryOnOrderPlaced.php (tipico in L9/L10)
namespace App\Listeners;

use App\Events\OrderPlaced;
// Non implementa ShouldQueue, quindi verrà eseguito sincronamente

class UpdateInventoryOnOrderPlaced
{
    public function handle(OrderPlaced $event): void
    {
        // Logica per aggiornare l'inventario
        logger()->info("Listener UpdateInventoryOnOrderPlaced: Inventario aggiornato per ordine #{$event->order->id}");
        // foreach ($event->order->items as $item) {
        //     $item->product->decrementStock($item->quantity);
        // }
    }
}

3. Registrazione manuale nell'EventServiceProvider

Il cuore della registrazione in Laravel 9/10 (quando l'event discovery non era abilitato o usato) era la proprietà protetta $listen all'interno di app/Providers/EventServiceProvider.php.

// app/Providers/EventServiceProvider.php (Laravel 9/L10 style)
namespace App\Providers;

use App\Events\OrderPlaced;
use App\Events\UserRegistered; // Altro evento d'esempio
use App\Listeners\SendOrderConfirmationEmail;
use App\Listeners\UpdateInventoryOnOrderPlaced;
use App\Listeners\SendWelcomeEmailToUser; // Altro listener d'esempio
use Illuminate\Auth\Events\Registered; // Evento di Laravel Fortify/Breeze
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Event;

class EventServiceProvider extends ServiceProvider
{
    /**
     * Le mappature evento-listener per l'applicazione.
     *
     * @var array<class-string, array<int, class-string>>
     */
    protected $listen = [
        Registered::class => [ // Evento di Laravel per la registrazione utente
            SendEmailVerificationNotification::class,
        ],
        UserRegistered::class => [ // Il nostro evento custom
            SendWelcomeEmailToUser::class,
        ],
        OrderPlaced::class => [ // Il nostro evento custom per l'ordine
            SendOrderConfirmationEmail::class,         // Listener in coda
            UpdateInventoryOnOrderPlaced::class,     // Listener sincrono
        ],
        // ... decine o centinaia di altre mappature in applicazioni grandi
    ];

    public function boot(): void
    {
        parent::boot();
        // Qui si potevano registrare anche subscriber o listener anonimi
    }

    /**
     * Determina se gli eventi e i listener devono essere scoperti automaticamente.
     * Nelle app L9/L10 che usano $listen, questo era spesso false.
     *
     * @return bool
     */
    public function shouldDiscoverEvents(): bool
    {
        return false; // Tipico se si usa l'array $listen estensivamente
    }
}

Limiti di questo approccio tradizionale:

  • File EventServiceProvider enorme: in applicazioni complesse con molti eventi e listener, questo file può diventare molto lungo e difficile da navigare, violando il principio di singola responsabilità.
  • Rischio di errori/omissioni: è facile dimenticare di registrare un nuovo listener o commettere un errore di battitura nel namespace dell'evento o del listener.
  • Accoppiamento configurazionale: la definizione del legame tra un evento e il suo listener è fisicamente separata dalla classe del listener stesso, rendendo meno immediato capire cosa ascolta un listener senza consultare l'EventServiceProvider.
  • Meno intuitivo per nuovi sviluppatori: trovare tutti i listener per un dato evento richiede di cercare in questo file centralizzato.

Verso Laravel 12: Event Discovery e Attributi #[AsEventListener]

Laravel 11 ha snellito la struttura applicativa di default e ha abilitato l'event discovery per impostazione predefinita. Questo, combinato con l'uso degli attributi PHP 8 (in particolare #[AsEventListener]), offre un modo molto più pulito e moderno per gestire gli eventi, pratica che si consolida in Laravel 12.

1. Event Discovery Automatico

Quando l'event discovery è abilitato (il metodo shouldDiscoverEvents() nell'EventServiceProvider ritorna true, che è il default in Laravel 11+, o se il metodo non esiste proprio), Laravel scansiona automaticamente una o più directory (di default app/Listeners) alla ricerca di classi listener. Se un listener ha un metodo handle che fa il type-hint di una classe evento (es. public function handle(OrderPlaced $event)), Laravel registrerà automaticamente quel listener per quell'evento.

È possibile personalizzare le directory da scansionare sovrascrivendo il metodo discoverEventsWithin() nell'EventServiceProvider:

// app/Providers/EventServiceProvider.php (Laravel 11/12)
// protected function discoverEventsWithin(): array
// {
//     return [
//         $this->app->path('Listeners'),
//         $this->app->path('Domain/Orders/Listeners'), // Esempio di directory custom
//     ];
// }

2. L'Attributo #[AsEventListener]

Per un controllo ancora più esplicito e per rendere la registrazione indipendente dalle convenzioni di nomenclatura del metodo (cioè, non dover per forza chiamare il metodo handle), Laravel ha introdotto l'attributo #[AsEventListener] (che fa uso del componente symfony/event-dispatcher-contracts).

Questo attributo PHP 8 può essere applicato direttamente sopra la classe del listener o sopra un metodo specifico all'interno della classe.

Guida pratica al refactoring:

Passo 1: Abilitare l'Event Discovery (se non già attivo) Nel tuo app/Providers/EventServiceProvider.php (se ancora lo usi per altre ragioni, come la registrazione di subscriber o listener anonimi), assicurati che:

// app/Providers/EventServiceProvider.php (per Laravel 11/12)
public function shouldDiscoverEvents(): bool
{
    return true; // Abilita l'event discovery
}

In una nuova applicazione Laravel 11+, questo è già il comportamento di default e potresti non avere nemmeno il metodo shouldDiscoverEvents() definito, il che va bene.

Passo 2: Refactoring dei Listener per usare #[AsEventListener] Ora puoi modificare le tue classi listener per utilizzare l'attributo.

Esempio 1: Listener singolo per un evento (come SendWelcomeEmailToUser)

// app/Listeners/SendWelcomeEmailToUser.php (Laravel 11/12 style)
namespace App\Listeners;

use App\Events\UserRegistered; // L'evento che questo listener gestisce
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener; // Importa l'attributo

#[AsEventListener(event: UserRegistered::class)] // La magia è qui!
class SendWelcomeEmailToUser implements ShouldQueue
{
    use InteractsWithQueue;

    public function __construct()
    {
        // Puoi ancora avere dipendenze iniettate qui
    }

    public function handle(UserRegistered $event): void
    {
        logger()->info("Listener SendWelcomeEmailToUser (via attributo): Email di benvenuto inviata a utente #{$event->user->id}");
        // ... logica invio email ...
    }
}

Con l'attributo #[AsEventListener(event: UserRegistered::class)], non è più necessario registrare questo listener nell'array $listen dell'EventServiceProvider. Laravel lo scoprirà e lo registrerà automaticamente.

Esempio 2: Una singola classe Listener che gestisce più eventi o metodi specifici Puoi avere una classe che funge da subscriber per più eventi, o che usa metodi diversi da handle.

// app/Listeners/OrderEventSubscriber.php (Laravel 11/12 style)
namespace App\Listeners;

use App\Events\OrderPlaced;
use App\Events\OrderShipped;
use App\Events\OrderCancelled;
use Illuminate\Contracts\Queue\ShouldQueue; // Per l'intera classe o per metodi specifici
use Illuminate\Queue\InteractsWithQueue;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

// Se l'intera classe deve essere in coda per tutti i suoi metodi listener:
// class OrderEventSubscriber implements ShouldQueue
class OrderEventSubscriber
{
    // use InteractsWithQueue; // Necessario se la classe implementa ShouldQueue

    #[AsEventListener(event: OrderPlaced::class, method: 'onOrderPlaced')]
    public function onOrderPlaced(OrderPlaced $event): void
    {
        // Questo metodo gestirà l'evento OrderPlaced
        logger()->info("OrderEventSubscriber: Ordine Piazzato #{$event->order->id}");
        // Se vuoi che *questo specifico ascoltatore* sia in coda, e la classe no,
        // dovresti creare un listener separato che implementa ShouldQueue.
        // L'attributo di per sé non rende un metodo accodabile se la classe non è ShouldQueue.
    }

    // Se il nome del metodo è 'handle', e l'attributo è sulla classe,
    // oppure se l'attributo sul metodo non specifica 'method',
    // e il metodo fa il type-hint dell'evento, funziona con l'event discovery.
    // Per chiarezza con più metodi, specificare `event` e `method` è una buona pratica.
    #[AsEventListener(event: OrderShipped::class, method: 'onOrderShipped', priority: 10)]
    public function onOrderShipped(OrderShipped $event): void
    {
        logger()->info("OrderEventSubscriber: Ordine Spedito #{$event->order->id} (priorità 10)");
    }

    // Questo metodo può essere scoperto automaticamente se la classe è nella directory dei listener
    // e se OrderCancelled è un evento, e shouldDiscoverEvents() è true.
    // L'attributo lo rende esplicito.
    #[AsEventListener(OrderCancelled::class)] // Il metodo 'handle' è implicito per l'evento specificato
    public function handle(OrderCancelled $event): void
    {
        logger()->info("OrderEventSubscriber: Ordine Cancellato #{$event->order->id} (metodo handle)");
    }
}

Importante sui Listener in Coda con Attributi: Se vuoi che un listener (o uno specifico metodo listener) venga eseguito in coda, la classe del listener deve implementare l'interfaccia ShouldQueue. L'attributo #[AsEventListener] di per sé non determina se il listener è in coda; si occupa solo della registrazione. Laravel controllerà se la classe del listener (o il job che lo wrappa, se si usa ShouldQueueReturning), implementa ShouldQueue.

Passo 3: Snellire o Rimuovere le Registrazioni Manuali da $listen Una volta che i tuoi listener sono stati refactorati per usare l'attributo #[AsEventListener] e l'event discovery è attivo, puoi (e dovresti) rimuovere le voci corrispondenti dall'array $listen nel tuo app/Providers/EventServiceProvider.php. Questo renderà il tuo EventServiceProvider significativamente più pulito. Potrebbe persino diventare quasi vuoto se tutte le tue mappature evento-listener passano a questo nuovo sistema. Questa modernizzazione della gestione eventi si sposa perfettamente con la nuova struttura applicativa snella di Laravel 11/12, che mira a ridurre il boilerplate nei Service Provider.

Considerazioni Aggiuntive

  • Priorità dei Listener: L'attributo #[AsEventListener] accetta un parametro priority (intero, più alto = prima esecuzione) per controllare l'ordine di esecuzione dei listener per lo stesso evento. #[AsEventListener(event: MyEvent::class, priority: 100)]
  • Listener Anonimi (Closures): Per logiche molto semplici e non riutilizzabili, puoi ancora registrare listener basati su closure direttamente nel metodo boot() del tuo EventServiceProvider (o in bootstrap/app.php per Laravel 11+) usando Event::listen(MyEvent::class, function (MyEvent $event) { ... });.
  • Subscriber di Eventi: Se una classe ascolta molti eventi, puoi ancora creare un Event Subscriber (una classe con metodi subscribe($events)) e registrarlo manualmente. Tuttavia, l'approccio con più attributi #[AsEventListener] su metodi diversi all'interno di una singola classe listener offre una flessibilità simile con una registrazione più automatica.

Testing

Il testing degli eventi e dei listener non cambia drasticamente, ma diventa più focalizzato.

  • Event::fake(): Continua a essere lo strumento principale per testare che gli eventi vengano inviati correttamente e con i dati attesi, senza eseguire i listener reali.

      // tests/Feature/OrderCreationTest.php
      use App\Events\OrderPlaced;
      use App\Models\Order;
      use Illuminate\Support\Facades\Event;
      // ...
      public function test_order_placed_event_is_dispatched_on_order_creation(): void
      {
          Event::fake(); // Previene l'esecuzione dei listener
    
          // Logica che crea un ordine e dovrebbe inviare l'evento OrderPlaced
          $order = $this->createOrder(); // Metodo helper
    
          Event::assertDispatched(OrderPlaced::class, function ($event) use ($order) {
              return $event->order->id === $order->id;
          });
          Event::assertDispatchedTimes(OrderPlaced::class, 1);
      }
  • Test Unitari dei Listener: Poiché i listener sono ora classi PHP più standard (specialmente se la loro registrazione è gestita da attributi), puoi testarli unitariamente in modo più semplice. Istanzia il listener, crea un'istanza fittizia dell'evento con i dati necessari, e chiama il metodo handle (o il metodo specificato nell'attributo), asserendo sui risultati o sugli effetti collaterali (es. mocking del Mail facade).

      // tests/Unit/Listeners/SendOrderConfirmationEmailTest.php
      namespace Tests\Unit\Listeners;
    
      use App\Events\OrderPlaced;
      use App\Listeners\SendOrderConfirmationEmail;
      use App\Models\Order;
      use App\Models\User; // Assumendo che Order abbia una relazione con User
      use Illuminate\Support\Facades\Mail;
      use App\Mail\OrderConfirmationMailable; // Ipotetico Mailable
      use Illuminate\Foundation\Testing\RefreshDatabase;
      use Tests\TestCase;
    
      class SendOrderConfirmationEmailTest extends TestCase
      {
          use RefreshDatabase;
    
          public function test_handle_sends_order_confirmation_email(): void
          {
              Mail::fake(); // Fai il fake del Mail facade
    
              $user = User::factory()->create(['email' => '[email protected]']);
              $order = Order::factory()->for($user)->create(); // Crea un ordine fittizio
              
              $event = new OrderPlaced($order);
              $listener = new SendOrderConfirmationEmail();
    
              $listener->handle($event); // Esegui il listener
    
              // Verifica che il Mailable corretto sia stato inviato all'utente corretto
              Mail::assertSent(OrderConfirmationMailable::class, function ($mail) use ($user, $order) {
                  return $mail->hasTo($user->email) &&
                         $mail->order->id === $order->id;
              });
          }
      }

Benefici del refactoring per le applicazioni della tua impresa

Adottare l'event discovery e gli attributi #[AsEventListener] per la gestione eventi nelle tue applicazioni Laravel (specialmente evolvendo da L9/L10 a L12) offre vantaggi significativi:

  • Codice più Pulito e Organizzato: La logica di registrazione è co-locata con il listener stesso, migliorando la coesione e rendendo più facile capire quali eventi gestisce una classe.
  • Riduzione del Rischio di Errori: Meno configurazione manuale significa minori possibilità di errori di battitura o di dimenticare di registrare un listener.
  • Migliore Developer Experience (DX): Aggiungere, modificare o rimuovere listener diventa un'operazione più semplice e intuitiva, senza dover navigare e modificare un (potenzialmente enorme) EventServiceProvider.
  • EventServiceProvider Snello: Questo file può concentrarsi su registrazioni più complesse o listener anonimi, se ancora necessari, diventando più leggero e manutenibile.
  • Allineamento con le Pratiche Moderne di Laravel e PHP: Sfrutta appieno le funzionalità del framework e del linguaggio.

Il ruolo del programmatore Laravel esperto

Intraprendere un refactoring del sistema di eventi, specialmente in un'applicazione aziendale di una certa dimensione, può beneficiare dell'occhio esperto di un senior laravel developer. Posso aiutare la tua impresa a:

  • Identificare i listener e le registrazioni che possono essere modernizzate.
  • Implementare l'approccio con attributi in modo efficiente e corretto.
  • Ristrutturare i listener per massimizzare la testabilità unitaria.
  • Assicurare che la transizione avvenga senza regressioni, attraverso test adeguati.

La mia esperienza ventennale mi permette di guidare la tua attività attraverso queste evoluzioni tecniche, assicurando che il tuo codice non solo funzioni, ma sia anche un piacere da mantenere. Per saperne di più sul mio approccio, visita la pagina Chi Sono.

Modernizzare la gestione degli eventi in Laravel 12 è un investimento nella chiarezza, nella robustezza e nella futura evoluzione della tua applicazione. È un passo verso un codice più elegante che supporta meglio le esigenze del tuo business.

Se sei pronto a snellire il tuo EventServiceProvider e a rendere la gestione degli eventi nella tua applicazione Laravel più intuitiva e moderna, contattami per discutere di come possiamo implementare queste best practice.

Ultima modifica: Mercoledì 26 Febbraio 2025, alle 10:37

Nelle applicazioni aziendali costruite con Laravel, capita frequentemente di dover eseguire operazioni batch che processano una grande quantità di dati o interagiscono con molteplici sistemi esterni. Che si tratti di inviare migliaia di notifiche email, sincronizzare dati da diverse fonti, generare report complessi o effettuare chiamate multiple a API di terze parti, l'approccio tradizionale di esecuzione sequenziale, comune in progetti Laravel 9 o Laravel 10 più datati, può rapidamente diventare un collo di bottiglia. Lunghi tempi di esecuzione per i comandi Artisan, richieste web che vanno in timeout e una generale scarsa reattività possono impattare negativamente sull'efficienza di un'impresa.

Fortunatamente, a partire da Laravel 11 (e quindi pienamente disponibile e consigliato per Laravel 12), il framework ha introdotto un potente componente per la gestione della concorrenza: Illuminate\Support\Facades\Concurrency. Questo strumento permette di eseguire più task contemporaneamente, sfruttando meglio le risorse del server e riducendo drasticamente i tempi di attesa, specialmente per operazioni I/O-bound.

In questo articolo tecnico, esploreremo come effettuare un refactoring di operazioni batch sequenziali, tipiche di un'applicazione Laravel 9/10, verso un approccio concorrente utilizzando Concurrency::run() e Concurrency::awaitall(), con esempi di codice dettagliati per illustrare i benefici in termini di performance per il tuo business.

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.

Scenario pre-concurrency: le operazioni batch sequenziali in Laravel 9/10

Immaginiamo alcuni scenari comuni in cui l'esecuzione sequenziale può diventare problematica.

Esempio 1: Invio di notifiche multiple a utenti

Supponiamo di dover inviare una notifica importante a tutti gli utenti attivi di un'applicazione. Un approccio sequenziale potrebbe essere:

// In un comando Artisan o un Service (Laravel 9/10 style)
use App\Models\User;
use App\Notifications\ImportantBusinessUpdate; // Ipotetica notifica
use Illuminate\Support\Facades\Log;

// ...
$users = User::where('is_active', true)->get();
$startTime = microtime(true);
$processedCount = 0;

foreach ($users as $user) {
    try {
        $user->notify(new ImportantBusinessUpdate());
        $processedCount++;
        // Log::info("Notifica inviata a {$user->email}");
    } catch (\Exception $e) {
        Log::error("Errore invio notifica a {$user->email}: " . $e->getMessage());
    }
    // Se ci sono migliaia di utenti, questo loop può durare molto tempo,
    // specialmente se l'invio email (anche se in coda) ha un piccolo overhead.
}

$duration = microtime(true) - $startTime;
Log::info("Invio notifiche completato a {$processedCount} utenti in {$duration} secondi.");

Se ogni notifica impiega anche solo mezzo secondo per essere processata (inclusa l'interazione con il sistema di code, se usato), inviare 10.000 notifiche richiederebbe quasi un'ora e mezza!

Esempio 2: Chiamate multiple a un'API esterna

Immagina di dover arricchire i dati di N prodotti nel tuo database chiamando un'API esterna per ognuno.

// In un Service (Laravel 9/10 style)
use Illuminate\Support\Facades\Http; // Uso base dell'HTTP Client
use App\Models\Product;
use Illuminate\Support\Facades\Log;

// ...
$products = Product::whereNull('external_data_enriched_at')->take(100)->get();
$startTime = microtime(true);
$updatedCount = 0;

foreach ($products as $product) {
    try {
        $response = Http::timeout(5)->get("https://api.thirdparty.com/v1/product-info/{$product->external_id}");

        if ($response->successful()) {
            $product->external_data = $response->json();
            $product->external_data_enriched_at = now();
            $product->save();
            $updatedCount++;
        } else {
            Log::warning("Fallimento API per prodotto {$product->id}: " . $response->status());
        }
    } catch (\Illuminate\Http\Client\ConnectionException $e) {
        Log::error("Errore di connessione API per prodotto {$product->id}: " . $e->getMessage());
        // Potresti voler interrompere o riprovare dopo un po'
        break; // Esempio drastico: interrompe al primo errore di connessione
    } catch (\Exception $e) {
        Log::error("Errore generico per prodotto {$product->id}: " . $e->getMessage());
    }
}

$duration = microtime(true) - $startTime;
Log::info("Arricchimento dati completato per {$updatedCount} prodotti in {$duration} secondi.");

Se ogni chiamata API impiega in media 1 secondo (tra latenza di rete e tempo di risposta del server esterno), arricchire 100 prodotti richiederebbe almeno 100 secondi, ovvero più di un minuto e mezzo. Questo potrebbe essere accettabile per un comando Artisan notturno, ma proibitivo per una richiesta web.

Limiti dell'approccio sequenziale:

  • Tempi di esecuzione lunghi: scalano linearmente con il numero di operazioni.
  • Blocco del processo principale: un comando Artisan rimane occupato per l'intera durata; una richiesta web andrebbe sicuramente in timeout.
  • Scarsa reattività: l'applicazione non può fare altro mentre attende il completamento di queste lunghe operazioni.

Modernizzare con Illuminate\Support\Facades\Concurrency (introdotto in Laravel 11, per L12)

Il componente Concurrency di Laravel (disponibile tramite il facade Illuminate\Support\Facades\Concurrency) permette di eseguire un insieme di task in modo concorrente, attendendo poi il loro completamento. Questo è particolarmente efficace per operazioni I/O-bound (come chiamate HTTP, interazioni con il database, operazioni sul filesystem), dove il processo spenderebbe molto tempo in attesa.

Driver di Concorrenza: Laravel supporta diversi driver per la concorrenza, configurabili tramite la variabile d'ambiente CONCURRENCY_DRIVER (default async):

  • async: Utilizza le fibre (PHP fibers) se disponibili (PHP 8.1+ e estensione fibers installata) per una concorrenza leggera e cooperativa all'interno di un singolo processo. Se le fibre non sono disponibili, ripiega sull'utilizzo di proc_open per eseguire task in processi separati, il che può avere un overhead maggiore ma funziona su più versioni di PHP.
  • fork: Utilizza l'estensione PHP pcntl per creare processi figli reali (forking). Questo può permettere vero parallelismo su sistemi multi-core, ed è spesso più adatto per task CPU-bound, ma richiede l'estensione pcntl (generalmente non disponibile su Windows o in alcuni ambienti PHP-FPM restrittivi).
  • fake: Un driver per il testing, che esegue i task sequenzialmente ma permette di asserire sulle operazioni di concorrenza.

Per la maggior parte delle operazioni I/O-bound come chiamate API o notifiche, il driver async con le fibre (se disponibili) è una scelta eccellente e performante.

1. Utilizzo di Concurrency::run()

Il metodo Concurrency::run() accetta un array di closure o oggetti invokable (i tuoi task) e li esegue in modo concorrente. Ritorna un array con i risultati di ogni task, nello stesso ordine in cui i task sono stati definiti.

Refactoring Esempio 1: Invio Notifiche Concorrente

// In un comando Artisan o un Service (Laravel 11/12 style)
use App\Models\User;
use App\Notifications\ImportantBusinessUpdate;
use Illuminate\Support\Facades\Concurrency;
use Illuminate\Support\Facades\Log;
use Throwable; // Per il catch generico

// ...
$users = User::where('is_active', true)->get(); // Supponiamo N utenti
$startTime = microtime(true);
$tasks = [];

foreach ($users as $user) {
    // Ogni task è una closure. Puoi anche usare classi Invokable.
    $tasks[] = function () use ($user) {
        try {
            $user->notifyNow(new ImportantBusinessUpdate()); // Usiamo notifyNow per l'esecuzione immediata nel contesto del task
            // Nota: se ImportantBusinessUpdate implementa ShouldQueue, notifyNow la processerà
            // immediatamente nel task concorrente, non la invierà alla coda principale.
            // Se vuoi inviarla alla coda principale da qui, usa $user->notify().
            return ['user_id' => $user->id, 'status' => 'success'];
        } catch (Throwable $e) { // Cattura qualsiasi eccezione per non bloccare gli altri task
            Log::error("Errore invio notifica (concorrente) a utente {$user->id}: " . $e->getMessage());
            return ['user_id' => $user->id, 'status' => 'failed', 'error' => $e->getMessage()];
        }
    };
}

// Esegui i task in concorrenza.
// Il secondo argomento è un callback opzionale eseguito per ogni task completato (anche se ha fallito internamente).
// Il terzo argomento è un callback opzionale per eccezioni non catturate *all'interno* delle closure dei task.
// Il quarto argomento è un timeout generale per tutti i task (in millisecondi).
$results = Concurrency::run(
    $tasks,
    function (array $result, int $index) { // $result è ciò che la closure del task ha ritornato
        if ($result['status'] === 'success') {
            // Log::info("Task notifica [{$index}] per utente {$result['user_id']} completato.");
        }
    },
    function (Throwable $e, int $index) {
        Log::critical("Eccezione non gestita dal task di notifica [{$index}]: " . $e->getMessage(), ['exception' => $e]);
    },
    timeout: 60000 // Timeout di 60 secondi per l'intero batch
);

$duration = microtime(true) - $startTime;
$successfulNotifications = count(array_filter($results, fn($r) => is_array($r) && $r['status'] === 'success'));
$failedNotifications = count($users) - $successfulNotifications;

Log::info("Invio notifiche (concorrente) completato. Successi: {$successfulNotifications}, Fallimenti: {$failedNotifications}. Durata: {$duration} secondi.");

Con questo approccio, se hai 100 utenti e ogni notifica (nel suo task isolato) impiega 0.5s, il tempo totale non sarà 50s, ma molto più vicino al tempo dell'operazione singola più lunga (o limitato dal numero di fibre/processi concorrenti che Laravel può gestire, configurabile tramite config('queue.connections.database.concurrent_tasks') per il driver async con proc_open o internamente per le fibre).

Refactoring Esempio 2: Chiamate Multiple a un'API Esterna in Concorrenza Un caso d'uso molto comune per la concorrenza è l'esecuzione di multiple chiamate a API esterne. Per approfondire come strutturare queste chiamate singolarmente in modo robusto, ti consiglio di leggere la mia guida al refactoring delle integrazioni API esterne in Laravel. Qui, ci concentreremo sull'eseguirle in concorrenza.

// In un Service (Laravel 11/12 style)
use Illuminate\Support\Facades\Concurrency;
use Illuminate\Support\Facades\Http; // Usiamo l'HTTP Client di Laravel
use Illuminate\Support\Facades\Log;
use App\Models\Product; // Il tuo model
use Throwable;

// ...
$productIdsToEnrich = Product::whereNull('external_data_enriched_at')->limit(50)->pluck('id', 'external_api_id');
$startTime = microtime(true);
$apiTasks = [];

// Supponiamo che external_api_id sia l'ID da usare con l'API esterna
foreach ($productIdsToEnrich as $externalApiId => $internalProductId) {
    $apiTasks[$internalProductId] = function () use ($externalApiId, $internalProductId) { // Usiamo l'ID interno come chiave per facilitare il matching dei risultati
        try {
            // Assicurati che il tuo HTTP Client sia configurato con timeout appropriati
            // per evitare che un singolo task blocchi tutto per troppo tempo.
            $response = Http::timeout(10) // Timeout per questa specifica chiamata
                              ->acceptJson()
                              ->get("https://api.thirdparty.com/v1/product-info/{$externalApiId}");

            if ($response->successful()) {
                return ['product_id' => $internalProductId, 'status' => 'success', 'data' => $response->json()];
            } else {
                Log::warning("Fallimento API (concorrente) per external ID {$externalApiId} (prodotto ID {$internalProductId}): " . $response->status());
                return ['product_id' => $internalProductId, 'status' => 'failed', 'http_status' => $response->status()];
            }
        } catch (Throwable $e) {
            Log::error("Errore chiamata API (concorrente) per external ID {$externalApiId} (prodotto ID {$internalProductId}): " . $e->getMessage());
            return ['product_id' => $internalProductId, 'status' => 'exception', 'error' => $e->getMessage()];
        }
    };
}

// Esegui i task, impostando un numero massimo di task concorrenti
// Questo è configurabile globalmente in config/concurrency.php o per driver.
// Qui è un esempio di come potresti volerlo limitare programmaticamente se necessario,
// anche se Concurrency::run gestisce il pool internamente.
// La configurazione avviene in config/concurrency.php -> stores.async.concurrent_tasks
$results = Concurrency::run($apiTasks, timeout: 45000); // Timeout generale 45s

$updatedCount = 0;
foreach ($results as $internalProductId => $result) { // Se abbiamo usato chiavi associative per $apiTasks
    if (is_array($result) && $result['status'] === 'success' && isset($result['data'])) {
        $product = Product::find($internalProductId);
        if ($product) {
            $product->external_data = $result['data'];
            $product->external_data_enriched_at = now();
            $product->save();
            $updatedCount++;
        }
    }
    // Altrimenti, gestisci i fallimenti o le eccezioni come loggato all'interno del task
}

$duration = microtime(true) - $startTime;
Log::info("Arricchimento dati (concorrente) completato. Prodotti aggiornati: {$updatedCount}. Durata: {$duration} secondi.");

Nota sul Timeout: Il timeout in Concurrency::run() è un timeout generale per il completamento di tutti i task. Ogni chiamata HTTP all'interno dei task dovrebbe avere il suo timeout() individuale per evitare che un singolo task lento blocchi l'intero batch indefinitamente.

2. Utilizzo di Concurrency::defer() e Concurrency::awaitall()

Per scenari più dinamici o per gestire un pool di task a cui se ne aggiungono altri nel tempo, puoi usare Concurrency::defer() per accodare un task al pool di concorrenza senza bloccare l'esecuzione. Successivamente, Concurrency::awaitall() attenderà che tutti i task nel pool (o un array specifico di PendingTask ritornati da defer) siano completati.

use Illuminate\Support\Facades\Concurrency;
use Illuminate\Support\Facades\Log;
use App\Models\DataToProcess; // Esempio

// ...
$dataItems = DataToProcess::cursor(); // Usa un cursore per gestire grandi set di dati
$pendingTasks = [];
$startTime = microtime(true);

foreach ($dataItems as $item) {
    // defer() aggiunge il task al pool e ritorna un'istanza di PendingTask
    $pendingTasks[] = Concurrency::defer(function () use ($item) {
        try {
            // Simula un'operazione I/O-bound o CPU-bound leggera
            // Per operazioni CPU-bound reali, considera il driver 'fork' e le sue implicazioni
            sleep(random_int(1,3)); // Simula lavoro
            $result = "Processed: " . $item->name;
            // Log::info($result);
            return ['item_id' => $item->id, 'result' => $result];
        } catch (Throwable $e) {
            Log::error("Errore processando item {$item->id}: " . $e->getMessage());
            return ['item_id' => $item->id, 'error' => $e->getMessage()];
        }
    });

    // Potresti voler limitare il numero di task 'deferiti' contemporaneamente
    // se il set di dati è enorme, per non esaurire la memoria.
    // Ad esempio, ogni N defer, fai un awaitall parziale.
    if (count($pendingTasks) % 50 === 0) { // Esempio: attendi ogni 50 task
        Log::info("In attesa di un batch parziale di " . count($pendingTasks) . " task...");
        $partialResults = Concurrency::awaitall($pendingTasks, timeout: 120000); // Timeout 2 minuti
        // Processa $partialResults qui...
        $pendingTasks = []; // Resetta l'array per il prossimo batch
    }
}

// Attendi i task rimanenti
if (!empty($pendingTasks)) {
    Log::info("In attesa dei task rimanenti (" . count($pendingTasks) . ")...");
    $finalResults = Concurrency::awaitall($pendingTasks, timeout: 120000);
    // Processa $finalResults qui...
}

$duration = microtime(true) - $startTime;
Log::info("Processo batch con defer/awaitall completato in {$duration} secondi.");

Limiti e Considerazioni sulla Concorrenza in PHP/Laravel

  • Non è (sempre) vero parallelismo: con il driver async basato su fibre o proc_open, la concorrenza è gestita all'interno di un singolo processo PHP (o più processi figli con proc_open). Questo è eccellente per task I/O-bound (attesa di rete, filesystem, database) perché il processo può passare ad altri task mentre uno è in attesa. Per task genuinamente CPU-bound, il driver fork (che usa pcntl) può offrire vero parallelismo su macchine multi-core, ma ha requisiti specifici (estensione pcntl, non disponibile su Windows).
  • Overhead: per task molto piccoli e veloci, l'overhead della gestione della concorrenza potrebbe superare i benefici. Valuta caso per caso.
  • Gestione delle risorse: troppi task concorrenti (specialmente se proc_open crea molti processi o se il driver fork è usato massivamente) possono esaurire le risorse del server (CPU, memoria). È possibile configurare il numero massimo di task concorrenti in config/concurrency.php.
  • Condivisione dello stato e race conditions: i task concorrenti dovrebbero essere il più possibile indipendenti. La condivisione di stato mutabile tra task concorrenti può portare a race conditions. Le fibre mitigano alcuni di questi problemi rispetto al threading tradizionale, ma è sempre bene progettare i task per essere stateless o per usare meccanismi di sincronizzazione sicuri se la condivisione è inevitabile.
  • Gestione delle eccezioni: come mostrato, è importante gestire le eccezioni all'interno di ogni task per evitare che un singolo fallimento blocchi l'intero batch, a meno che non sia il comportamento desiderato. Concurrency::run() e awaitall() possono anche avere callback globali per il catch.

Sinergia con il Process Facade

Per operazioni batch che richiedono l'esecuzione di comandi CLI esterni (es. script Python, tool di elaborazione immagini come ImageMagick), il Process facade (introdotto in Laravel 10) può essere utilizzato all'interno dei task gestiti da Concurrency::run(). Questo permette di orchestrare più processi esterni in modo concorrente.

// Esempio concettuale
use Illuminate\Support\Facades\Concurrency;
use Illuminate\Support\Facades\Process;

$filesToConvert = ['image1.jpg', 'image2.png', 'image3.webp'];
$conversionTasks = [];

foreach ($filesToConvert as $file) {
    $conversionTasks[] = function () use ($file) {
        $result = Process::timeout(60)->run("convert {$file} -resize 50% output/{$file}");
        if ($result->successful()) {
            return "Conversione di {$file} riuscita.";
        }
        return "Fallimento conversione {$file}: " . $result->errorOutput();
    };
}
$conversionResults = Concurrency::run($conversionTasks);
dump($conversionResults);

Benefici del Refactoring per la tua Impresa

Adottare il componente Concurrency per le operazioni batch che erano sequenziali in Laravel 9/10 porta a:

  • Drastica riduzione dei tempi di esecuzione: specialmente per task I/O-bound.
  • Migliore utilizzo delle risorse del server: il server non rimane inattivo durante le attese I/O.
  • Applicazioni e comandi Artisan più reattivi: migliora l'esperienza utente e l'efficienza operativa.
  • Capacità di gestire carichi di lavoro più grandi: permette di processare più dati o più richieste nello stesso lasso di tempo.
  • Codice più moderno e allineato: sfrutta le ultime innovazioni del framework Laravel.

Il Ruolo del Programmatore Laravel Esperto

Identificare quali operazioni batch beneficiano maggiormente della concorrenza, scegliere il driver giusto, gestire correttamente lo stato, le eccezioni e i timeout, e integrare il tutto in un'applicazione esistente richiede esperienza. Come programmatore laravel esperto e senior laravel developer, posso assistere la tua impresa in:

  • Analisi delle performance delle attuali operazioni batch.
  • Refactoring del codice sequenziale per utilizzare Concurrency::run() o defer()/awaitall().
  • Ottimizzazione dei task per la concorrenza.
  • Implementazione di strategie di logging e monitoraggio per i processi concorrenti.
  • Scrittura di test per i nuovi componenti concorrenti.

Per un approfondimento sul mio approccio e sulla mia esperienza ventennale, ti invito a visitare la mia pagina Chi Sono.

Sfruttare la concorrenza in Laravel 12 non è solo un tecnicismo; è una strategia per rendere il tuo business più efficiente, scalabile e capace di rispondere rapidamente alle esigenze del mercato.

Se la tua applicazione Laravel è frenata da operazioni batch lente e vuoi esplorare come la concorrenza possa rivoluzionare le tue performance, contattami per una consulenza mirata.

Ultima modifica: Giovedì 20 Febbraio 2025, alle 13:04

Nelle moderne applicazioni web aziendali sviluppate con Laravel, i Job inviati a una coda (Queue) giocano un ruolo cruciale. Permettono di delegare operazioni lunghe o onerose (come l'invio di email, l'elaborazione di immagini o video, la generazione di report complessi, l'interazione con API esterne) a processi background, mantenendo l'applicazione reattiva per l'utente. Tuttavia, se da un lato le code migliorano le performance e l'esperienza utente, dall'altro introducono complessità nel testing. Assicurarsi che questi job asincroni funzionino correttamente in ogni scenario è fondamentale per l'affidabilità di un business.

Se la tua impresa utilizza applicazioni Laravel 9 o Laravel 10, potresti avere strategie di testing per i job in coda che sono diventate lente, fragili o difficili da mantenere. Con le evoluzioni del framework, specialmente quelle introdotte in Laravel 11 e consolidate in Laravel 12, esistono ora tecniche molto più efficaci per testare i job in modo rapido, preciso e isolato. Questo articolo tecnico ti guiderà nel refactoring delle tue strategie di testing dei job, sfruttando appieno Queue::fake() e la potente novità withFakeQueueInteractions().

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.

Scenario pre-refactoring: le sfide del testing dei Job in Laravel 9/10

Nelle versioni di Laravel precedenti all'introduzione di strumenti di testing più sofisticati per le code, o in progetti che non li hanno adottati appieno, testare i job poteva seguire diversi approcci, ognuno con i suoi compromessi.

Approccio 1: Utilizzo del sync driver per i test

Una tecnica comune era impostare il driver della coda su sync durante i test (tramite il file .env.testing o phpunit.xml).

<php>
    <env name="QUEUE_CONNECTION" value="sync"/>
</php>

Questo fa sì che i job inviati alla coda vengano eseguiti immediatamente, nello stesso processo del test.

  • Vantaggi: Semplice da configurare; il job viene eseguito e puoi asserire sui suoi effetti collaterali (es. modifiche al database, invio di notifiche fake).
  • Svantaggi:
    • Non testa realmente il meccanismo della coda (serializzazione del job, interazione con il broker della coda).
    • Può mascherare problemi di serializzazione che si verificherebbero solo con un driver asincrono.
    • Se il metodo handle() del job è complesso o esegue operazioni lunghe, i test diventano lenti.
    • È difficile testare scenari come il delay di un job o il suo invio a una coda specifica.

Approccio 2: Test di integrazione con code reali

Un altro approccio consisteva nell'usare un driver di coda reale (come database o redis) anche nell'ambiente di test, e far girare un worker della coda o processare la coda manualmente nel test.

  • Vantaggi: Molto realistico, testa l'intero flusso.
  • Svantaggi:
    • Setup complesso: richiede la configurazione del broker della coda nell'ambiente di test.
    • Test estremamente lenti: l'attesa che il worker processi il job introduce latenza.
    • Gestione dello stato: bisogna assicurarsi che la coda sia pulita tra un test e l'altro per evitare interferenze.
    • Dipendenza da servizi esterni: se si usa Redis o un database come broker, questi devono essere attivi e funzionanti.
    • Difficile asserire sul momento esatto in cui il job viene processato o sui suoi tentativi.

Approccio 3: Mocking manuale del Dispatcher o Bus facade

Si poteva tentare di mockare il dispatcher della coda per verificare che il metodo dispatch venisse chiamato con il job corretto.

// Esempio concettuale (potrebbe variare leggermente in base alla versione di Laravel/Mockery)
use Illuminate\Support\Facades\Bus;
use App\Jobs\MySampleJob;
use App\Models\User; // Esempio

// ... nel metodo di test ...
Bus::fake(); // Previene l'invio reale

$user = User::factory()->create();
// Azione che dovrebbe dispacciare il job
$this->app['some_service']->performActionThatDispatchesJob($user);

Bus::assertDispatched(MySampleJob::class, function ($job) use ($user) {
    // Verifica che il job sia stato istanziato con i dati corretti
    return $job->user->id === $user->id;
});

Laravel ha poi introdotto Bus::fake() e Queue::fake() che sono molto più potenti e dedicati, ma le prime implementazioni di mocking potevano essere più manuali e meno espressive.

  • Vantaggi: Isolamento, test veloci.
  • Svantaggi (del mocking manuale più datato): Poteva essere verboso, non si testava la serializzazione del job o se fosse effettivamente "accodabile". Le asserzioni potevano essere meno intuitive.

Problematiche comuni di questi approcci legacy: suite di test lente che scoraggiano l'esecuzione frequente, test fragili che falliscono per ragioni non direttamente collegate alla logica del job, e una generale mancanza di fiducia nella copertura dei test per i processi asincroni. Questo è un debito tecnico che può costare caro a un'impresa in termini di bug non rilevati in produzione.

Modernizzare il testing dei Job in Laravel 12 (sfruttando le fondamenta di L10/L11)

Laravel offre strumenti sofisticati per superare queste sfide. Vediamo come effettuare un refactoring delle tue strategie di testing.

1. Queue::fake(): la base per il testing moderno dei Job

Il facade Queue fornisce il metodo fake(), che sostituisce il gestore delle code con un'implementazione fake che registra i job inviati senza effettivamente accodarli o eseguirli. Questo è il punto di partenza per testare che i tuoi job vengano inviati correttamente.

Come funziona: Chiamando Queue::fake() all'inizio del tuo test (tipicamente nel metodo setUp() o all'inizio del metodo di test specifico), tutte le successive chiamate a dispatch(), MyJob::dispatch(), o Bus::dispatch() non invieranno realmente il job al broker della coda. Invece, il job verrà memorizzato in una collezione interna al faker, permettendoti di fare asserzioni su di esso.

Asserzioni Principali con Queue::fake():

* Queue::assertPushed(string|Closure $job, int|Closure|null $callback = null): Verifica che un job di un certo tipo sia stato inviato. Il secondo argomento può essere il numero di volte o una closure per ispezionare l'istanza del job. * Queue::assertPushedOn(string $queue, string|Closure $job, int|Closure|null $callback = null): Verifica che un job sia stato inviato a una coda specifica. * Queue::assertPushedWithChain(string $job, array $expectedChain = [], Closure|null $callback = null): Verifica che un job sia stato inviato con una specifica catena di job. * Queue::assertNotPushed(string|Closure $job, Closure|null $callback = null): Verifica che un job NON sia stato inviato.

  • Queue::assertNothingPushed(): Verifica che nessun job sia stato inviato.

* Queue::pushed(string $job, Closure|null $callback = null): Restituisce una collezione di job inviati che corrispondono al tipo e opzionalmente alla closure, permettendo asserzioni più complesse.

Esempio di Test Refactorato (dall'Approccio 1 o 2): Supponiamo di avere un OrderProcessingService che, dopo aver creato un ordine, invia un SendOrderConfirmationEmailJob.

// app/Jobs/SendOrderConfirmationEmailJob.php
namespace App\Jobs;

use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail; // Esempio
use App\Mail\OrderConfirmationMail; // Esempio

class SendOrderConfirmationEmailJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public Order $order;

    public function __construct(Order $order)
    {
        $this->order = $order;
        // $this->onQueue('emails'); // Esempio: specificare la coda
        // $this->delay(now()->addSeconds(5)); // Esempio: specificare un delay
    }

    public function handle(): void
    {
        // Logica di invio email
        // Mail::to($this->order->customer_email)->send(new OrderConfirmationMail($this->order));
        logger("Email di conferma inviata per l'ordine: " . $this->order->id);
    }
}

// tests/Feature/OrderProcessingTest.php
namespace Tests\Feature;

use App\Jobs\SendOrderConfirmationEmailJob;
use App\Models\User;
use App\Models\Order; // Assumendo che esista
use App\Services\OrderProcessingService;
use Illuminate\Support\Facades\Queue;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class OrderProcessingTest extends TestCase
{
    use RefreshDatabase;

    public function test_order_confirmation_email_job_is_pushed_upon_order_creation(): void
    {
        Queue::fake(); // ATTIVA IL FAKE PRIMA DELL'AZIONE

        $user = User::factory()->create();
        $orderData = ['product_id' => 1, 'quantity' => 2]; // Dati fittizi

        $service = new OrderProcessingService(); // Il servizio che crea l'ordine e invia il job
        $order = $service->processNewOrder($user, $orderData);

        Queue::assertPushed(SendOrderConfirmationEmailJob::class, function ($job) use ($order) {
            // Verifica che il job sia stato inviato con l'istanza corretta dell'ordine
            return $job->order->id === $order->id;
        });

        // Puoi anche verificare il numero di volte che è stato inviato
        Queue::assertPushed(SendOrderConfirmationEmailJob::class, 1);

        // Se il job specificava una coda o una connessione:
        // Queue::assertPushedOn('emails', SendOrderConfirmationEmailJob::class);
        // Queue::assertPushed(SendOrderConfirmationEmailJob::class, function ($job) {
        //     return $job->connection === 'redis' && $job->queue === 'emails' && $job->delay == 5;
        // });
    }
}

Questo test è veloce, isolato e verifica precisamente che il job corretto sia stato inviato con i dati corretti, senza eseguire il suo metodo handle().

2. Refactoring dei Job per una Migliore Testabilità Unitaria del handle()

Per testare la logica interna del metodo handle() di un job, è una best practice che questa logica sia il più possibile "pura" o che le sue dipendenze siano facilmente iniettabili e mockabili.

  • Estrarre logica complessa: Se il tuo metodo handle() contiene molta logica di business, considera di estrarla in una classe di servizio dedicata. Il job diventerà un semplice orchestratore che chiama metodi di questo servizio.
  • Dependency Injection: Inietta le dipendenze nel costruttore del tuo job invece di usare facade o app() helper direttamente nel metodo handle() (sebbene Laravel gestisca bene l'iniezione anche nei metodi handle tramite il service container). Questo facilita il mocking durante i test unitari del job stesso.

3. withFakeQueueInteractions() (Laravel 11+): Testare la Logica Interna del Job in Isolamento

Laravel 11 ha introdotto il trait Illuminate\Foundation\Testing\WithFakes e il metodo withFakeQueueInteractions() per i job, che risolve una sfida specifica: come testare unitariamente un job che, all'interno del suo metodo handle(), interagisce con il sistema delle code (ad esempio, chiamando $this->release(), $this->delete(), o $this->fail())?

Come funziona: Quando testi un job specifico, puoi istanziarlo e chiamare ->withFakeQueueInteractions() su di esso. Successivamente, quando chiami il suo metodo handle(), qualsiasi chiamata a release(), delete(), o fail() verrà intercettata e registrata, permettendoti di fare asserzioni su queste interazioni senza che il job venga effettivamente rilasciato, cancellato o marcato come fallito nel broker della coda.

Implementazione Pratica e Esempi di Test: Supponiamo un job ProcessWebhookJob che processa un webhook e, a seconda del payload, potrebbe rilasciarsi per un secondo tentativo o fallire.

// app/Jobs/ProcessWebhookJob.php
namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Services\WebhookProcessorService; // Servizio che contiene la logica di business
use Throwable; // Per l'eccezione nel fail()

class ProcessWebhookJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public array $payload;
    public int $attempts = 0; // Per tracciare i tentativi manualmente, se necessario

    // Numero massimo di tentativi prima di fallire permanentemente
    public int $maxAttempts = 3;

    // Delay per il rilascio in secondi
    public int $releaseDelay = 60;

    public function __construct(array $payload)
    {
        $this->payload = $payload;
    }

    public function handle(WebhookProcessorService $processor): void
    {
        $this->attempts++; // Incrementa i tentativi all'inizio dell'handle

        try {
            $result = $processor->process($this->payload);

            if ($result === 'retry_later') {
                if ($this->attempts() < $this->maxAttempts) { // Usa $this->attempts() di InteractsWithQueue
                    logger("Webhook {$this->payload['id']}: Rilascio (tentativo {$this->attempts()})...");
                    $this->release($this->releaseDelay); // Rilascia il job per un nuovo tentativo
                    return;
                } else {
                    logger()->error("Webhook {$this->payload['id']}: Max tentativi raggiunti. Fallimento.");
                    $this->fail(new \Exception("Max tentativi ({$this->maxAttempts}) raggiunti per webhook {$this->payload['id']}."));
                    return;
                }
            } elseif ($result === 'invalid_payload') {
                logger()->warning("Webhook {$this.payload['id']}: Payload non valido. Cancellazione job.");
                $this->delete(); // Cancella il job se il payload non è valido e non va ritentato
                return;
            }

            // Se tutto ok, il job termina e viene rimosso dalla coda
            logger("Webhook {$this->payload['id']} processato con successo.");

        } catch (Throwable $e) {
            logger()->error("Webhook {$this.payload['id']}: Eccezione durante processamento. Fallimento. Errore: " . $e->getMessage());
            // Puoi decidere se rilasciare o fallire in base al tipo di eccezione
            $this->fail($e); // Fallisce il job a causa di un'eccezione inattesa
        }
    }
}

// tests/Unit/ProcessWebhookJobTest.php
namespace Tests\Unit\Jobs;

use App\Jobs\ProcessWebhookJob;
use App\Services\WebhookProcessorService;
use Illuminate\Foundation\Testing\RefreshDatabase; // Se il job interagisce con il DB
use Tests\TestCase;
use Mockery;
use Mockery\MockInterface;

class ProcessWebhookJobTest extends TestCase
{
    // use RefreshDatabase; // Aggiungere se il job o il servizio interagiscono con il DB

    private MockInterface $webhookProcessorMock;

    protected function setUp(): void
    {
        parent::setUp();
        $this->webhookProcessorMock = Mockery::mock(WebhookProcessorService::class);
        // Inietta il mock nel container per quando il job viene risolto
        // o passalo direttamente al metodo handle() se il job lo permette.
        // Per un test unitario del job, è meglio iniettarlo.
        // Qui assumiamo che handle() lo prenda come argomento per semplicità
    }

    public function test_job_releases_itself_when_processor_returns_retry_later_and_attempts_ok(): void
    {
        $payload = ['id' => 'wh_123', 'data' => 'some_data'];
        $job = new ProcessWebhookJob($payload);
        // $job->maxAttempts = 3; // Puoi impostare proprietà pubbliche per il test
        // $job->releaseDelay = 30;

        $this->webhookProcessorMock
            ->shouldReceive('process')
            ->with($payload)
            ->once()
            ->andReturn('retry_later');

        // CHIAVE: Attiva il fake per le interazioni con la coda
        $job->withFakeQueueInteractions();
        $job->handle($this->webhookProcessorMock); // Esegui il metodo handle

        $job->assertReleased(60); // Asserisci che $this->release(60) è stato chiamato
                                  // Il valore di default di releaseDelay è 60 nel Job
        $job->assertNotDeleted();
        $job->assertNotFailed();
    }

    public function test_job_fails_when_processor_returns_retry_later_and_max_attempts_reached(): void
    {
        $payload = ['id' => 'wh_456', 'data' => 'other_data'];
        $job = new ProcessWebhookJob($payload);
        $job->maxAttempts = 1; // Forziamo il raggiungimento max tentativi

        $this->webhookProcessorMock
            ->shouldReceive('process')
            ->with($payload)
            ->once()
            ->andReturn('retry_later');

        $job->withFakeQueueInteractions();
        // Simuliamo che il job sia già stato tentato una volta (il primo tentativo è quello corrente)
        // Per testare il numero di tentativi, il job deve avere accesso al suo `attempts()`
        // che withFakeQueueInteractions() dovrebbe gestire.
        // Il job stesso incrementa $this->attempts. Per testare il limite,
        // la logica $this->attempts() < $this->maxAttempts nel job è cruciale.
        // Se $this->attempts() nel job parte da 1 (nel primo run),
        // e maxAttempts è 1, la condizione $this->attempts() < $this->maxAttempts fallirà.
        // Nota: la proprietà `attempts` pubblica nel job è stata aggiunta per debug,
        // ma il job dovrebbe usare il metodo `attempts()` fornito da `InteractsWithQueue`.
        // `withFakeQueueInteractions` dovrebbe simulare `attempts()` per iniziare da 1.

        $job->handle($this->webhookProcessorMock); // Primo e unico tentativo permesso

        $job->assertFailed(function (Throwable $e) { // Asserisci che $this->fail() è stato chiamato
            return str_contains($e->getMessage(), 'Max tentativi (1) raggiunti');
        });
        $job->assertNotReleased();
        $job->assertNotDeleted();
    }

    public function test_job_deletes_itself_when_processor_returns_invalid_payload(): void
    {
        $payload = ['id' => 'wh_789', 'data' => 'invalid'];
        $job = new ProcessWebhookJob($payload);

        $this->webhookProcessorMock
            ->shouldReceive('process')
            ->with($payload)
            ->once()
            ->andReturn('invalid_payload');

        $job->withFakeQueueInteractions();
        $job->handle($this->webhookProcessorMock);

        $job->assertDeleted(); // Asserisci che $this->delete() è stato chiamato
        $job->assertNotReleased();
        $job->assertNotFailed();
    }

    public function test_job_completes_successfully_when_processor_succeeds(): void
    {
        $payload = ['id' => 'wh_abc', 'data' => 'valid_data'];
        $job = new ProcessWebhookJob($payload);

        $this->webhookProcessorMock
            ->shouldReceive('process')
            ->with($payload)
            ->once()
            ->andReturn('success'); // Assumendo che 'success' sia un esito positivo

        $job->withFakeQueueInteractions();
        $job->handle($this->webhookProcessorMock);

        $job->assertNotReleased();
        $job->assertNotDeleted(); // A meno che il job non si auto-cancelli al successo
        $job->assertNotFailed();
        // Qui potresti anche asserire che il logger ha scritto il messaggio di successo
    }
}

Nota su withFakeQueueInteractions() e attempts(): Il trait InteractsWithQueue fornisce un metodo attempts() che restituisce il numero di volte che il job è stato tentato. withFakeQueueInteractions dovrebbe fornire un ambiente in cui questo metodo restituisce 1 alla prima esecuzione di handle() nel test, permettendo di testare correttamente la logica basata sui tentativi.

Vantaggi del Testing Moderno dei Job

  • Test più veloci: Eseguire il metodo handle() in isolamento è molto più rapido che processare una coda reale.
  • Test più affidabili: Nessuna dipendenza da broker di code esterni o da temporizzazioni.
  • Isolamento della logica del Job: Puoi concentrarti sulla correttezza della logica interna del tuo job.
  • Maggiore resilienza: Una test suite con questo livello di precisione aumenta la resilienza nel rilasciare modifiche.
  • Facilità di Debug: Errori nei job sono più facili da individuare.

Il Ruolo del Programmatore Laravel Esperto

Il refactoring dei job e, soprattutto, delle strategie di testing per passare da approcci legacy a queste tecniche moderne richiede una buona comprensione del sistema di code di Laravel e dei pattern di testing avanzati. Come programmatore laravel esperto, posso aiutare la tua impresa a:

  • Analizzare i job esistenti e le loro attuali coperture di test.
  • Ristrutturare i job per massimizzare la testabilità e la manutenibilità.
  • Implementare suite di test robuste utilizzando Queue::fake() e withFakeQueueInteractions().
  • Integrare questi test nei tuoi processi di CI/CD per garantire la qualità continua.

La mia esperienza nello sviluppo di applicazioni Laravel complesse e scalabili mi permette di guidare la tua attività verso l'adozione di queste best practice. Per saperne di più, visita la mia pagina Chi Sono.

I job in coda sono spesso il motore silenzioso di molte funzionalità critiche per un business. Assicurarsi che siano robusti, affidabili e ben testati non è un lusso, ma una necessità. Modernizzare le tue pratiche di sviluppo e testing in quest'area, specialmente se stai evolvendo la tua applicazione verso Laravel 12, è un investimento che ripaga in termini di stabilità e velocità di innovazione.

Se la tua applicazione Laravel fa un uso intensivo delle code e vuoi migliorare la qualità e la testabilità dei tuoi job, contattami per una consulenza specifica. Possiamo trasformare i tuoi processi asincroni in un punto di forza della tua architettura software.

Ultima modifica: Mercoledì 19 Febbraio 2025, alle 08:36

Modernizzare i Model Eloquent Laravel: guida al refactoring da $casts array (L9/L10) al potente metodo casts() in Laravel 12

L'Eloquent ORM è una delle funzionalità più apprezzate del framework PHP Laravel, grazie alla sua sintassi elegante e alla facilità con cui permette di interagire con il database. Una delle sue caratteristiche fondamentali è la capacità di effettuare il cast degli attributi di un Model a tipi di dati specifici, garantendo che i dati siano nel formato corretto quando vengono recuperati o impostati. Per anni, questo è stato gestito principalmente tramite la proprietà $casts (un array) definita nel Model. Tuttavia, con Laravel 11 (e il cui approccio è consolidato e consigliato per Laravel 12), è stato introdotto il metodo casts(), che offre un modo più flessibile, potente ed espressivo per definire le conversioni di tipo.

Se la tua impresa lavora con applicazioni Laravel 9 o Laravel 10 e desideri modernizzare il codice, migliorarne la leggibilità e la manutenibilità, il refactoring dalla proprietà $casts al metodo casts() è un passo significativo e vantaggioso. Questo articolo tecnico ti guiderà attraverso questo processo, con abbondanti esempi di codice.

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.

L'approccio tradizionale: la proprietà $casts in Laravel 9/10

Nelle versioni di Laravel precedenti alla 11 (quindi Laravel 9 e 10 incluse), il modo standard per definire i cast degli attributi era attraverso la proprietà pubblica o protetta $casts all'interno del Model Eloquent.

Come funziona: Si definisce un array associativo dove la chiave è il nome dell'attributo e il valore è il tipo a cui si desidera effettuare il cast.

Esempi di cast comuni con la proprietà $casts: Consideriamo un ipotetico Model Product.php:

// app/Models/Product.php (Laravel 9/10 style)
namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection; // Per il cast a 'collection'

class Product extends Model
{
    use HasFactory;

    protected $fillable = [
        'name',
        'description',
        'price_in_cents',
        'stock_quantity',
        'is_published',
        'options',        // Ad esempio, un attributo JSON
        'features',       // Ad esempio, un altro attributo JSON da castare a Collection
        'available_from',
        'last_ordered_at',
        'secret_code',    // Esempio di dato da crittografare
    ];

    /**
     * The attributes that should be cast.
     *
     * @var array<string, string>
     */
    protected $casts = [
        'price_in_cents' => 'integer',
        'stock_quantity' => 'integer',
        'is_published' => 'boolean',
        'options' => 'array', // Converte da/a JSON string nel database
        'features' => 'collection', // Converte da/a JSON string e restituisce una Illuminate\Support\Collection
        'available_from' => 'date', // Converte a oggetto Carbon, salva come Y-m-d
        'last_ordered_at' => 'datetime', // Converte a oggetto Carbon, salva come Y-m-d H:i:s
        // 'release_timestamp' => 'timestamp', // Converte a intero Unix timestamp
        // 'custom_object_data' => 'object', // Converte da/a JSON e restituisce un oggetto stdClass
        'secret_code' => 'encrypted', // Crittografa/Decrittografa automaticamente
    ];
}

// Esempio di utilizzo:
// $product = Product::find(1);
// dump($product->price_in_cents); // Sarà un integer
// dump($product->is_published);   // Sarà un boolean
// dump($product->options);        // Sarà un array PHP
// dump($product->features);       // Sarà una Illuminate\Support\Collection
// dump($product->available_from); // Sarà un'istanza di Carbon\Carbon
// dump($product->secret_code);    // Sarà automaticamente decrittografato al recupero
// $product->secret_code = 'nuovo valore'; // Sarà automaticamente crittografato al salvataggio

Limiti dell'approccio con l'array $casts: Sebbene funzionale per molti casi d'uso, questo metodo presenta alcune limitazioni:

  • Difficoltà nel passare opzioni/argomenti ai cast: Se si desidera un formato data specifico (diverso da quello di default di Laravel) o una precisione particolare per un numero decimale, spesso si doveva ricorrere a accessor e mutator custom, rendendo il Model più verboso. Per esempio, datetime:Y-m-d è supportato, ma per logiche più complesse la sintassi diventa limitata.
  • Minore riutilizzabilità della logica di cast complessa: Se più Model necessitano della stessa logica di cast personalizzata, non c'è un modo pulito per condividerla usando solo l'array $casts.
  • Il Model può diventare verboso: Con molti attributi che richiedono cast, l'array $casts può crescere considerevolmente.
  • Meno espressività per cast a oggetti valore complessi: Castare un attributo a un Value Object custom (un oggetto che rappresenta un valore semplice ma con una sua logica, es. Address, Money) richiedeva sempre accessor/mutator o classi di cast custom (che erano possibili anche prima, ma meno integrate).

La rivoluzione con il metodo casts() in Laravel 11 (e consolidato in L12)

Laravel 11 ha introdotto la possibilità di definire i cast tramite un metodo casts() all'interno del Model. Questo approccio non solo è più pulito, ma apre la porta a funzionalità più avanzate e a una migliore organizzazione del codice. In Laravel 12, questo è il metodo preferito e consigliato.

Sintassi di base del metodo casts(): Si definisce un metodo protetto casts() che ritorna un array associativo, simile alla proprietà $casts.

// app/Models/Product.php (Laravel 11/12 style)
namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Database\Eloquent\Casts\AsCollection; // Per cast a collection più specifici
use Illuminate\Database\Eloquent\Casts\AsArrayObject; // Per cast ad ArrayObject
use Illuminate\Database\Eloquent\Casts\AsEncryptedArrayObject; // Per array crittografati

class Product extends Model
{
    use HasFactory;

    // $fillable rimane invariato
    protected $fillable = [
        'name', 'description', 'price_in_cents', 'stock_quantity', 'is_published',
        'options', 'features', 'available_from', 'last_ordered_at', 'secret_code',
        'custom_settings', 'more_secrets'
    ];

    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string|\Illuminate\Contracts\Database\Eloquent\CastsAttributes>
     */
    protected function casts(): array
    {
        return [
            'price_in_cents' => 'integer',
            'stock_quantity' => 'integer',
            'is_published' => 'boolean',
            'options' => 'array',
            'features' => AsCollection::class, // Più esplicito, usa la classe di cast fornita da Laravel
            // 'features' => 'collection', // Questo funziona ancora per backward compatibility
            'available_from' => 'date',
            'last_ordered_at' => 'datetime:Y-m-d H:i:sP', // Ora è più facile specificare formati!
            'secret_code' => 'encrypted',
            'custom_settings' => AsArrayObject::class, // Cast a un oggetto ArrayObject
            'more_secrets' => AsEncryptedArrayObject::class, // Come AsArrayObject ma crittografato
            'precise_amount' => 'decimal:2', // Cast a float/string con 2 cifre decimali
            'another_precise_amount' => 'decimal:4',
        ];
    }
}

Refactoring degli esempi precedenti: Come puoi vedere, molti dei cast base rimangono simili, ma l'uso di classi As* fornite da Laravel (come AsCollection) è più esplicito e apre la strada a una maggiore coerenza. La possibilità di specificare argomenti direttamente nella stringa del cast (es. 'datetime:Y-m-d H:i:sP', 'decimal:2') è un grande vantaggio.

Sfruttare la potenza del metodo casts(): Enum, Oggetti Valore e Castable Personalizzati

Il vero potere del metodo casts() si manifesta quando si lavora con tipi di dati più complessi o si necessita di logica di cast personalizzata.

1. Cast a Enum di PHP 8.1+

Con PHP 8.1 sono state introdotte le Enumerations (Enum). Laravel (già da versioni precedenti a L11, ma il metodo casts() lo rende più pulito) supporta nativamente il cast di attributi a oggetti Enum.

Definizione di una Enum:

// app/Enums/ProductStatus.php
namespace App\Enums;

enum ProductStatus: string
{
    case DRAFT = 'draft';
    case PENDING_REVIEW = 'pending_review';
    case PUBLISHED = 'published';
    case ARCHIVED = 'archived';
}

Cast nel Model:

// app/Models/Product.php
// ...
use App\Enums\ProductStatus;

class Product extends Model
{
    // ...
    protected function casts(): array
    {
        return [
            // ... altri cast
            'status' => ProductStatus::class, // Casting diretto alla classe Enum
        ];
    }
}

// Esempio di utilizzo:
// $product = Product::find(1);
// if ($product->status === ProductStatus::PUBLISHED) {
//     // Fai qualcosa
// }
// $product->status = ProductStatus::ARCHIVED;
// $product->save(); // Verrà salvato come 'archived' nel database (il backing value della Enum)

Questo garantisce type safety, migliora la leggibilità e sfrutta l'autocompletamento dell'IDE.

2. Oggetti Castable Personalizzati (Castable e CastsAttributes)

Per logiche di cast complesse, riutilizzabili o che richiedono dependency injection, puoi creare le tue classi di cast personalizzate. Ci sono due interfacce principali coinvolte:

  • Illuminate\Contracts\Database\Eloquent\CastsAttributes: la implementi per definire come un attributo viene trasformato quando letto (get) e quando scritto (set).
  • Illuminate\Contracts\Database\Eloquent\Castable: la implementi nel tuo Value Object (o in una classe dedicata) per specificare quale classe CastsAttributes usare per esso, eventualmente passando argomenti.

Esempio: Cast per un oggetto Address memorizzato come JSON

1. Crea il Value Object Address (opzionale, ma buona pratica):

// app/ValueObjects/Address.php
namespace App\ValueObjects;

// Semplice classe DTO/Value Object per l'esempio
class Address
{
    public function __construct(
        public readonly string $street,
        public readonly string $city,
        public readonly string $zipCode,
        public readonly string $country = 'IT' // Esempio con valore di default
    ) {}

    // Potresti aggiungere metodi per la validazione, formattazione, etc.
    public function toString(): string
    {
        return "{$this->street}, {$this->zipCode} {$this->city}, {$this->country}";
    }
}

2. Crea la classe di Cast AddressCast (implementa CastsAttributes):

// app/Casts/AddressCast.php
namespace App\Casts;

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use App\ValueObjects\Address; // Il nostro Value Object
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;

class AddressCast implements CastsAttributes
{
    /**
     * Cast the given value.
     *
     * @param  \Illuminate\Database\Eloquent\Model  $model
     * @param  string  $key
     * @param  mixed  $value  (valore dal database, stringa JSON)
     * @param  array<string, mixed>  $attributes (tutti gli attributi del model)
     * @return \App\ValueObjects\Address|null
     */
    public function get(Model $model, string $key, mixed $value, array $attributes): ?Address
    {
        if (is_null($value)) {
            return null;
        }

        $data = json_decode($value, true);
        if (json_last_error() !== JSON_ERROR_NONE) {
            // Gestisci l'errore di decodifica JSON come preferisci
            return null; // O lancia un'eccezione
        }

        return new Address(
            $data['street'] ?? '',
            $data['city'] ?? '',
            $data['zip_code'] ?? '',
            $data['country'] ?? 'IT'
        );
    }

    /**
     * Prepare the given value for storage.
     *
     * @param  \Illuminate\Database\Eloquent\Model  $model
     * @param  string  $key
     * @param  mixed  $value  (istanza di Address o array)
     * @param  array<string, mixed>  $attributes
     * @return string|null
     */
    public function set(Model $model, string $key, mixed $value, array $attributes): ?string
    {
        if (is_null($value)) {
            return null;
        }

        if ($value instanceof Address) {
            return json_encode([
                'street' => $value->street,
                'city' => $value->city,
                'zip_code' => $value->zipCode,
                'country' => $value->country,
            ]);
        }

        // Potresti gestire anche l'input come array, se vuoi flessibilità
        if (is_array($value) && isset($value['street'], $value['city'], $value['zip_code'])) {
             return json_encode([
                'street' => $value['street'],
                'city' => $value['city'],
                'zip_code' => $value['zip_code'],
                'country' => $value['country'] ?? 'IT',
            ]);
        }

        throw new InvalidArgumentException('Il valore fornito per l\'indirizzo non è valido.');
    }
}

3. Usa il Cast nel Model:

// app/Models/Order.php
namespace App\Models;

use App\Casts\AddressCast; // Importa la classe di cast
use Illuminate\Database\Eloquent\Model;

class Order extends Model
{
    // ...
    protected $fillable = ['customer_name', 'shipping_address_data', 'billing_address_data'];

    protected function casts(): array
    {
        return [
            'shipping_address_data' => AddressCast::class,
            'billing_address_data' => AddressCast::class, // Riutilizzabile!
        ];
    }

    // Ora puoi interagire con $order->shipping_address_data come un oggetto Address
    // $order->shipping_address_data = new Address('Via Roma 1', 'Torino', '10123');
    // $order->save();
    // dump($order->shipping_address_data->city); // Output: 'Torino'
}

Esempio di Castable con Argomenti (per maggiore controllo): Supponiamo di voler un cast MoneyCast che prenda la valuta come argomento.

1. Crea la classe MoneyCast che implementa CastsAttributes (simile ad AddressCast ma per un oggetto Money). 2. Crea una classe AsMoney che implementa Castable:

// app/Casts/AsMoney.php
namespace App\Casts;

use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

class AsMoney implements Castable
{
    /**
     * Get the caster class to use when casting from / to database.
     *
     * @param  array<string, mixed>  $arguments (es. ['EUR'] o ['USD', 2] per la precisione)
     * @return \Illuminate\Contracts\Database\Eloquent\CastsAttributes<\Illuminate\Database\Eloquent\Model, mixed>
     */
    public static function castUsing(array $arguments): CastsAttributes
    {
        // Qui ritorni un'istanza della tua classe che implementa CastsAttributes,
        // passandole gli $arguments.
        // return new MoneyCast($arguments[0] ?? 'EUR', $arguments[1] ?? 2);
        // Per questo esempio, la logica interna di MoneyCast non è mostrata per brevità.
        // Immagina che MoneyCast accetti valuta e precisione nel costruttore.
        return new MoneyCast(...$arguments); // Semplificato, MoneyCast gestirà gli argomenti
    }
}

// app/Casts/MoneyCast.php (struttura ipotetica)
namespace App\Casts;
use App\ValueObjects\Money; // Ipotetico Value Object
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;

class MoneyCast implements CastsAttributes {
    protected string $currency;
    protected int $decimals;

    public function __construct(string $currency = 'EUR', int $decimals = 2) {
        $this->currency = $currency;
        $this->decimals = $decimals;
    }
    public function get(Model $model, string $key, mixed $value, array $attributes): ?Money {
        // $value è l'importo in centesimi dal DB (assumiamo)
        if (is_null($value)) return null;
        return new Money((int) $value, $this->currency, $this->decimals);
    }
    public function set(Model $model, string $key, mixed $value, array $attributes): ?int {
        if (is_null($value)) return null;
        if (!$value instanceof Money) throw new \InvalidArgumentException('Not a Money object');
        // Salva l'importo in centesimi
        return $value->getAmountInCents();
    }
}

// app/ValueObjects/Money.php (struttura ipotetica)
namespace App\ValueObjects;
class Money {
    private int $amountInCents; private string $currency; private int $decimals;
    public function __construct(int $amountInCents, string $currency, int $decimals = 2) { /* ... */ }
    public function getAmountInCents(): int { return $this->amountInCents; }
    // ...
}

3. Usa AsMoney nel Model:

// app/Models/Product.php
use App\Casts\AsMoney;
// ...
protected function casts(): array
{
    return [
        // ...
        // 'price' memorizza l'importo in centesimi, ma viene gestito come oggetto Money
        'price' => AsMoney::class . ':EUR,2', // Passa EUR e 2 decimali come argomenti
        'cost' => AsMoney::class . ':USD,4',  // Un altro esempio con USD e 4 decimali
    ];
}
// $product->price = new Money(1000, 'EUR'); // 10.00 EUR
// dump($product->price->formatted()); // Esempio: "€10.00"

Laravel parsa la stringa dopo AsMoney::class . ':' e passa gli argomenti (separati da virgola) al metodo castUsing.

Vantaggi del Refactoring al Metodo casts() e Oggetti Castable

Modernizzare i tuoi Model Eloquent con questo approccio porta a:

  • Model più Puliti: la logica di cast complessa viene delegata a classi dedicate, mantenendo i Model snelli e focalizzati sui dati e le relazioni.
  • Logica di Cast Riutilizzabile: una classe Castable (come AddressCast o MoneyCast) può essere riutilizzata in più Model e per più attributi.
  • Testabilità Migliorata: le classi di cast possono essere testate individualmente, isolando la loro logica.
  • Maggiore Espressività e Type Safety: l'uso di Enum e Value Objects tipizzati migliora la robustezza e la chiarezza del codice.
  • Flessibilità: la possibilità di passare argomenti ai cast tramite castUsing offre un controllo granulare sul comportamento della conversione.

Il Ruolo del Programmatore Laravel Esperto

Sebbene il refactoring possa sembrare semplice per cast di base, la progettazione e l'implementazione di oggetti Castable personalizzati per logiche di business complesse, specialmente in un'impresa con una vasta base di codice legacy, richiede una mano esperta. Come senior laravel developer, posso aiutarti a:

  • Identificare gli attributi che beneficerebbero maggiormente di cast personalizzati.
  • Progettare Value Objects e classi Castable robuste e riutilizzabili.
  • Guidare il processo di refactoring in modo sicuro e incrementale.
  • Scrivere test per garantire la correttezza della nuova logica di cast.

Per una visione più approfondita del mio approccio ingegneristico e di come posso aiutare la tua attività a modernizzare i suoi applicativi Laravel, visita la mia pagina Chi Sono.

Modernizzare la gestione dei cast nei tuoi Model Eloquent non è solo una questione estetica; è un investimento nella qualità, nella manutenibilità e nella robustezza a lungo termine delle tue applicazioni Laravel. È un passo fondamentale per qualsiasi business che voglia mantenere il proprio software al passo con le best practice e le evoluzioni del framework.

Se sei pronto a elevare la qualità dei tuoi Model Eloquent e a sfruttare appieno le potenzialità di Laravel 12 per la gestione dei dati, contattami per discutere delle tue esigenze specifiche.

Ultima modifica: Lunedì 17 Febbraio 2025, alle 14:18

La sicurezza informatica è un processo continuo, non una destinazione. Per qualsiasi impresa che si affida ad applicazioni web sviluppate con Laravel, mantenere una postura di sicurezza robusta è fondamentale per proteggere i dati aziendali, le informazioni dei clienti e la reputazione del business. Due aspetti cruciali, spesso sottovalutati nella manutenzione a lungo termine di un'applicazione Laravel 9 o Laravel 10, sono la gestione della chiave di crittografia principale (APP_KEY) e la strategia di hashing delle password utente. Fortunatamente, Laravel 11 ha introdotto miglioramenti significativi in queste aree, funzionalità che diventano parte integrante di un'applicazione Laravel 12 moderna e sicura.

In questo articolo tecnico, esploreremo come effettuare un aggiornamento mirato della sicurezza delle credenziali, implementando la Graceful Encryption Key Rotation (rotazione sicura delle chiavi di crittografia) e l'Automatic Password Rehashing (rehashing automatico delle password). Vedremo, con esempi di codice dettagliati, come passare da un approccio più statico e potenzialmente rischioso, tipico delle versioni Laravel precedenti, a una gestione più dinamica e resiliente.

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.

La situazione della sicurezza credenziali in Laravel 9/10: cosa potrebbe mancare?

Nelle applicazioni Laravel 9 e 10, la gestione di APP_KEY e dell'hashing delle password è già robusta, ma presenta alcune rigidità se si necessita di evolvere le pratiche di sicurezza nel tempo.

Gestione dell'APP_KEY

L'APP_KEY è una stringa casuale di 32 byte, memorizzata nel file .env, utilizzata da Laravel per tutte le operazioni di crittografia e decrittografia (ad esempio, per i cookie crittografati, le sessioni, i valori di cache crittografati e qualsiasi dato che la tua applicazione crittografa esplicitamente usando il facade Crypt).

  • Scenario tipico L9/L10: l'APP_KEY viene generata una volta durante l'installazione (php artisan key:generate) e raramente viene modificata.
  • Il rischio: se questa chiave viene compromessa (ad esempio, a causa di un file .env esposto accidentalmente o di un accesso non autorizzato al server), tutti i dati crittografati con essa sono a rischio di decrittazione.
  • La sfida della rotazione: cambiare l'APP_KEY in un'applicazione L9/L10 senza una strategia specifica invaliderebbe immediatamente tutti i dati precedentemente crittografati. Questo include sessioni utente attive, cookie "remember me", e qualsiasi dato applicativo che hai crittografato. Ripristinare la funzionalità richiederebbe la re-crittografia manuale di tutti i dati o la loro perdita, causando significativi disservizi.

Hashing delle Password

Laravel utilizza di default l'algoritmo bcrypt per l'hashing delle password, che è una scelta sicura. La configurazione si trova in config/hashing.php.

// Esempio config/hashing.php (Laravel 9/10)
return [
    'driver' => 'bcrypt', // o 'argon', 'argon2id' se supportato e configurato

    'bcrypt' => [
        'rounds' => env('BCRYPT_ROUNDS', 10), // 'rounds' determina il costo computazionale
        'verify' => true,
    ],

    'argon' => [ // Argon2i
        'memory' => 1024,   // KiB
        'threads' => 2,
        'time' => 2,
        'verify' => true,
    ],

    'argon2id' => [ // Argon2id (generalmente preferito ad Argon2i)
        'memory' => 65536,  // 64MB
        'threads' => 1,
        'time' => 4,        // Numero di passaggi
        'verify' => true,
    ],
];
  • Scenario tipico L9/L10: i parametri di hashing (es. rounds per bcrypt) vengono impostati e rimangono generalmente invariati.
  • La sfida dell'aggiornamento dei parametri: con il tempo, la potenza di calcolo aumenta, e potrebbe diventare consigliabile aumentare il "costo" dell'hashing (es. i rounds di bcrypt da 10 a 12 o 14) per renderlo più resistente ad attacchi brute-force. Oppure, si potrebbe decidere di passare a un algoritmo più moderno come Argon2id.
  • Mancanza di rehashing automatico: se in Laravel 9/10 modifichi i parametri di hashing (es. aumenti i rounds), le password degli utenti già registrate (e hashate con i vecchi parametri) non vengono automaticamente aggiornate al nuovo standard. Questo significa che rimangono meno sicure rispetto a quelle dei nuovi utenti. Per aggiornarle, saresti costretto a implementare una logica custom che, al momento del login, verifichi la password, e se corretta e hashata con parametri obsoleti, la ri-hashi e la salvi.

Verso Laravel 12: le evoluzioni di Laravel 11 per la sicurezza avanzata

Laravel 11 ha introdotto due funzionalità chiave che affrontano direttamente queste sfide, rendendo le applicazioni Laravel 12 intrinsecamente più sicure e più facili da gestire nel tempo dal punto di vista della sicurezza delle credenziali.

1. Graceful Encryption Key Rotation (Rotazione Sicura delle Chiavi di Crittografia)

Questa funzionalità permette di ruotare l'APP_KEY senza invalidare immediatamente i dati crittografati con le chiavi precedenti.

Come funziona: Laravel 11+ ora supporta una nuova variabile d'ambiente: APP_PREVIOUS_KEYS. Questa variabile può contenere una lista separata da virgole di chiavi di crittografia precedentemente utilizzate. Quando l'applicazione deve decrittografare un valore:

  1. Tenta prima con l'attuale APP_KEY.
  2. Se fallisce, itera attraverso le chiavi in APP_PREVIOUS_KEYS (nell'ordine in cui sono elencate) e tenta di decrittografare. Tutte le nuove operazioni di crittografia utilizzeranno sempre e solo l'attuale APP_KEY.

Implementazione Pratica (Refactoring da L9/L10 a L12):

Supponiamo di voler ruotare l'APP_KEY di un'applicazione Laravel 9/10 che stiamo aggiornando a Laravel 12 (e che quindi ha già beneficiato delle feature di L11).

Passo 1: Assicurati che la tua applicazione sia su Laravel 11+

Questa funzionalità è disponibile da Laravel 11. Se stai aggiornando da L9/L10, questo è uno dei benefici che ottieni.

Passo 2: Identifica la tua attuale APP_KEY

Apri il tuo file .env:

APP_KEY=base64:vecchiaChiaveInB64...==

Passo 3: Aggiungi la variabile APP_PREVIOUS_KEYS al file .env

Inizialmente, potrebbe essere vuota o non presente. Se stai effettuando la prima rotazione "graceful":

APP_KEY=base64:vecchiaChiaveInB64..==
APP_PREVIOUS_KEYS=

Passo 4: Genera una nuova APP_KEY

Utilizza il comando Artisan:

php artisan key:generate

Questo aggiornerà la riga APP_KEY nel tuo .env con una nuova chiave. Non riavviare ancora l'applicazione se sei in produzione (o fallo in una finestra di manutenzione).

Passo 5: Sposta la vecchia APP_KEY in APP_PREVIOUS_KEYS

Prendi la APP_KEY che avevi prima della generazione (quella che ora è la "vecchia chiave") e mettila in APP_PREVIOUS_KEYS. Se APP_PREVIOUS_KEYS conteneva già altre chiavi, aggiungi la nuova vecchia chiave all'inizio della lista, separata da virgola.

Esempio aggiornato di .env:

APP_KEY=base64:nuovaChiaveInB64...==
APP_PREVIOUS_KEYS=base64:vecchiaChiaveInB64...==

Se avevi già una chiave precedente in APP_PREVIOUS_KEYS:

// Prima del key:generate e spostamento
APP_KEY=base64:chiaveAttualeInB64...==
APP_PREVIOUS_KEYS=base64:chiavePrecedenteInB64...==

// Dopo key:generate e spostamento
APP_KEY=base64:lultimaChiaveGenerataInB64...==
APP_PREVIOUS_KEYS=base64:chiaveAttualeInB64...==,base64:chiavePrecedenteInB64...==

Passo 6: Deploy e Verifica

Effettua il deploy della tua applicazione con il nuovo .env. L'applicazione ora userà APP_KEY per crittografare nuovi dati e sarà in grado di decrittografare i dati esistenti usando sia la nuova APP_KEY che quelle elencate in APP_PREVIOUS_KEYS.

Strategia di re-crittografia (opzionale ma consigliata): Sebbene i dati vecchi siano ancora leggibili, è una buona pratica re-crittografarli con la nuova APP_KEY per eliminare gradualmente la dipendenza dalle vecchie chiavi. Questo può essere fatto con un comando Artisan custom che recupera i dati crittografati, li decrittografa (verranno usate le chiavi appropriate) e li ri-crittografa (verrà usata la nuova APP_KEY). Una volta che sei sicuro che tutti i dati rilevanti siano stati ri-crittografati, puoi rimuovere la chiave più vecchia da APP_PREVIOUS_KEYS.

Questo processo rende la rotazione delle chiavi, un'operazione di sicurezza critica, molto meno dolorosa e rischiosa per la continuità del business.

2. Automatic Password Rehashing (Rehashing Automatico delle Password)

Questa funzionalità, anch'essa introdotta in Laravel 11, assicura che le password degli utenti siano sempre hashate con i parametri di hashing più recenti configurati per l'applicazione, senza richiedere alcuna azione manuale complessa.

Come funziona: Quando un utente effettua il login:

  1. Laravel verifica le credenziali fornite rispetto all'hash della password memorizzato nel database.
  2. Se la password è corretta, Laravel controlla anche se l'hash memorizzato è stato generato utilizzando i parametri di hashing attuali (definiti in config/hashing.php, es. l'algoritmo, i rounds per bcrypt, i parametri di memoria/tempo/thread per Argon2id).
  3. Se i parametri non corrispondono (cioè, l'hash è "obsoleto"), Laravel ri-hasherà automaticamente la password fornita (che è corretta) usando i nuovi parametri e aggiornerà l'hash nel database.

Tutto questo avviene in modo trasparente per l'utente.

Implementazione Pratica (Refactoring da L9/L10 a L12):

Passo 1: Assicurati che la tua applicazione sia su Laravel 11+

Passo 2: Verifica il tuo modello User Assicurati che il tuo modello App\Models\User (o qualsiasi modello utente tu stia usando) utilizzi il trait Illuminate\Auth\Authenticatable. Questo è lo standard nelle nuove applicazioni Laravel, ma in progetti più vecchi potrebbe essere stato personalizzato. Questo trait contiene la logica necessaria.

Passo 3: Aggiorna i parametri di hashing (se desiderato) Se vuoi aumentare la sicurezza (ad esempio, passare da rounds: 10 a rounds: 12 per bcrypt, o passare da bcrypt ad Argon2id), modifica il tuo file config/hashing.php:

// config/hashing.php (esempio di potenziamento)
return [
    'driver' => env('HASHING_DRIVER', 'bcrypt'), // Puoi anche cambiare il driver di default

    'bcrypt' => [
        'rounds' => env('BCRYPT_ROUNDS', 12), // Aumentato a 12 (o più, bilanciando sicurezza e performance)
        'verify' => true,
    ],

    'argon2id' => [
        'memory' => env('ARGON2_MEMORY_COST', 65536), // 64MB
        'threads' => env('ARGON2_THREADS_COST', 1),
        'time' => env('ARGON2_TIME_COST', 4),
        'verify' => true,
    ],
];

Se cambi il 'driver' predefinito, ad esempio a 'argon2id', assicurati che il tuo server PHP abbia il supporto per Argon2 compilato.

Passo 4: Verifica la lunghezza della colonna password L'hash prodotto da Argon2id è più lungo di quello prodotto da bcrypt. Se stai migrando ad Argon2id, assicurati che la colonna password nella tua tabella users sia sufficientemente lunga. Una VARCHAR(255) è generalmente sicura e spesso è già il default. Se fosse più corta (es. VARCHAR(60) usata in vecchissimi sistemi), dovrai creare una migrazione per alterare la colonna:

// Esempio di migrazione per modificare la lunghezza della colonna password
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('password', 255)->change(); // Da 60 (o altro) a 255
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            // Potresti voler tornare indietro, ma attenzione se hai già hash Argon2
            $table->string('password', 60)->change();
        });
    }
};

Passo 5: Lascia che Laravel faccia il suo lavoro! Non c'è altro da fare. Al prossimo login di ogni utente, se il suo hash non è aggiornato, Laravel lo aggiornerà silenziosamente.

Considerazioni sulle performance: il rehashing avviene durante il processo di login. Se i parametri di hashing sono molto costosi, questo potrebbe aggiungere una leggera latenza al primo login dopo l'aggiornamento dei parametri. Tuttavia, questo è un piccolo prezzo da pagare per una sicurezza significativamente migliorata.

Benefici di queste modernizzazioni per la sicurezza della tua impresa

L'implementazione di queste due funzionalità in un'applicazione Laravel che si sta evolvendo da L9/L10 a L12 offre vantaggi cruciali per la sicurezza e la manutenibilità:

  • Maggiore resilienza alla compromissione della chiave APP_KEY: la possibilità di ruotare le chiavi senza interrompere il servizio è un enorme passo avanti.
  • Password sempre protette con gli standard più recenti: il rehashing automatico garantisce che il tuo business non si affidi a password hashate con algoritmi o parametri progressivamente indeboliti dall'aumento della potenza di calcolo.
  • Riduzione del rischio di data breach: credenziali più sicure e chiavi di crittografia gestite meglio significano una minore probabilità di successo per gli attaccanti.
  • Miglioramento della postura di sicurezza complessiva: dimostra un impegno proattivo verso la sicurezza, importante anche per la fiducia dei clienti e la compliance normativa (es. GDPR).
  • Semplificazione della manutenzione della sicurezza: molte operazioni che prima richiedevano script custom o procedure manuali complesse sono ora gestite elegantemente dal framework.

Ovviamente, queste misure a livello applicativo devono essere supportate da un server adeguatamente "indurito", come discusso in precedenza, perché la sicurezza è una catena e ogni anello deve essere forte.

Il ruolo del consulente Laravel esperto

Introdurre la rotazione delle chiavi o modificare i parametri di hashing in un'applicazione legacy o di grandi dimensioni può sembrare intimidatorio. Un programmatore laravel esperto o un consulente specializzato in Laravel e sicurezza (come il sottoscritto, Maurizio Fonte) può:

  • Analizzare la tua attuale implementazione e identificare i rischi.
  • Pianificare e testare accuratamente il processo di aggiornamento e migrazione.
  • Sviluppare eventuali script custom necessari (es. per la re-crittografia proattiva dei dati).
  • Assicurare che la transizione avvenga senza perdita di dati o interruzione del servizio.
  • Fornire consulenza sulle best practice di sicurezza più recenti per Laravel.

La mia esperienza ventennale nello sviluppo software e nella gestione di infrastrutture complesse mi permette di affrontare questi aggiornamenti con una visione strategica. Per saperne di più sul mio approccio, visita la pagina Chi Sono.

Investire oggi nell'aggiornamento della sicurezza delle credenziali della tua applicazione Laravel è un passo fondamentale per proteggere il tuo business nel futuro. Con le funzionalità introdotte in Laravel 11 e consolidate in Laravel 12, questo processo è diventato più accessibile e sicuro che mai.

Se la tua impresa ha un'applicazione Laravel 9 o 10 e desideri portarla ai massimi livelli di sicurezza sfruttando queste nuove funzionalità in vista di un futuro con Laravel 12, contattami per una valutazione e una strategia su misura.

Ultima modifica: Venerdì 14 Febbraio 2025, alle 10:04

Il framework PHP Laravel è noto per la sua eleganza, la sua potenza e la sua continua evoluzione. Con il rilascio di Laravel 11, e il consolidamento di queste scelte in Laravel 12, è stata introdotta una significativa semplificazione della struttura applicativa di default. Questa modernizzazione mira a ridurre il boilerplate, a centralizzare la configurazione e a offrire un'esperienza di sviluppo ancora più fluida. Per le imprese e gli sviluppatori che mantengono applicazioni basate su Laravel 10 (o versioni precedenti come Laravel 9, che condividono una struttura simile), effettuare un refactoring per adottare questo nuovo approccio può portare a benefici tangibili in termini di chiarezza del codice e manutenibilità a lungo termine.

In questo articolo tecnico, ti guiderò attraverso i passaggi chiave per trasformare la struttura di un'applicazione Laravel 10 (che utilizza ancora i file Kernel HTTP e Console, e diversi Service Provider per la configurazione di base) verso l'architettura più snella e programmatica introdotta con Laravel 11 e considerata la norma per Laravel 12. Vedremo come centralizzare la configurazione di routing, middleware, gestione delle eccezioni e altro ancora nel file bootstrap/app.php.

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.

Prima di addentrarci nel refactoring, ricordiamo brevemente come è tipicamente strutturata la configurazione di base in un'applicazione Laravel 10 (o Laravel 9):

  • app/Http/Kernel.php: questo file è responsabile della definizione dei middleware HTTP globali, dei gruppi di middleware (come web e api) e degli alias dei middleware (precedentemente noti come $routeMiddleware).

      // Esempio parziale di app/Http/Kernel.php (Laravel 10)
      namespace App\Http;
    
      use Illuminate\Foundation\Http\Kernel as HttpKernel;
    
      class Kernel extends HttpKernel
      {
          protected $middleware = [
              // \App\Http\Middleware\TrustHosts::class,
              \App\Http\Middleware\TrustProxies::class,
              \Illuminate\Http\Middleware\HandleCors::class,
              // ... altri middleware globali
          ];
    
          protected $middlewareGroups = [
              'web' => [
                  \App\Http\Middleware\EncryptCookies::class,
                  // ... altri middleware del gruppo 'web'
              ],
              'api' => [
                  // \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
                  'throttle:api',
                  \Illuminate\Routing\Middleware\SubstituteBindings::class,
              ],
          ];
    
          protected $middlewareAliases = [ // In versioni più vecchie era $routeMiddleware
              'auth' => \App\Http\Middleware\Authenticate::class,
              // ... altri alias
          ];
          // ...
      }
  • app/Console/Kernel.php: definisce i comandi Artisan custom dell'applicazione e lo schedule dei task.

      // Esempio parziale di app/Console/Kernel.php (Laravel 10)
      namespace App\Console;
    
      use Illuminate\Console\Scheduling\Schedule;
      use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
    
      class Kernel extends ConsoleKernel
      {
          protected function schedule(Schedule $schedule): void
          {
              // $schedule->command('inspire')->hourly();
          }
    
          protected function commands(): void
          {
              $this->load(__DIR__.'/Commands');
              require base_path('routes/console.php');
          }
      }
  • app/Providers/RouteServiceProvider.php: si occupa tradizionalmente di caricare i file delle rotte (routes/web.php, routes/api.php), configurare i prefissi per le rotte API, i middleware associati e il rate limiting per le API. Gestisce anche il route model binding implicito e esplicito.

      // Esempio parziale di app/Providers/RouteServiceProvider.php (Laravel 10)
      namespace App\Providers;
    
      use Illuminate\Cache\RateLimiting\Limit;
      use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
      use Illuminate\Http\Request;
      use Illuminate\Support\Facades\RateLimiter;
      use Illuminate\Support\Facades\Route;
    
      class RouteServiceProvider extends ServiceProvider
      {
          public const HOME = '/dashboard';
    
          public function boot(): void
          {
              $this->configureRateLimiting();
    
              $this->routes(function () {
                  Route::middleware('api')
                      ->prefix('api')
                      ->group(base_path('routes/api.php'));
    
                  Route::middleware('web')
                      ->group(base_path('routes/web.php'));
              });
          }
    
          protected function configureRateLimiting(): void
          {
              RateLimiter::for('api', function (Request $request) {
                  return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
              });
          }
      }
  • app/Providers/AuthServiceProvider.php: registra le policy di autorizzazione e definisce i Gate per l'applicazione.

  • app/Exceptions/Handler.php: centralizza la logica di gestione delle eccezioni, permettendo di personalizzare come vengono riportate e renderizzate.

Sebbene questa struttura sia stata efficace per anni, può portare a una certa frammentazione della configurazione di base e a un po' di boilerplate (codice ripetitivo).

Verso Laravel 12: il cuore pulsante in bootstrap/app.php

Laravel 11 ha introdotto un approccio più snello e programmatico, dove gran parte della configurazione dell'applicazione viene centralizzata nel file bootstrap/app.php. Questo file diventa il vero punto di ingresso per definire come l'applicazione gestisce il routing, i middleware, le eccezioni, e anche i service provider (anche se l'obiettivo è spesso ridurne il numero per le configurazioni di base). Laravel 12 eredita e consolida questa filosofia.

Il nuovo bootstrap/app.php utilizza un'API fluente sull'oggetto Illuminate\Foundation\Application per configurare questi aspetti.

Guida pratica al refactoring: da Laravel 10 alla struttura di Laravel 11/12

Vediamo i passaggi per migrare un'applicazione Laravel 10 (o Laravel 9 con struttura simile) alla nuova architettura.

Passo 1: Preparazione e Backup

Prima di qualsiasi refactoring strutturale, è assolutamente fondamentale effettuare un backup completo del tuo progetto (codice e database) e assicurarti che la tua test suite sia robusta e copra le funzionalità critiche. La gestione delle versioni con Git è tua amica: crea un nuovo branch per queste modifiche. Assicurati anche che la tua versione di PHP sia compatibile con Laravel 11/12 (minimo PHP 8.2). Aggiorna la tua dipendenza laravel/framework nel composer.json a ^11.0 (o ^12.0 quando disponibile e stabile) e lancia composer update.

Passo 2: Creazione/Adattamento del nuovo bootstrap/app.php

Se stai aggiornando un progetto Laravel 10, probabilmente dovrai modificare significativamente il tuo bootstrap/app.php esistente o quasi ricrearlo basandoti sulla struttura di Laravel 11+. Un bootstrap/app.php minimale in stile Laravel 11/12 potrebbe assomigliare a questo:

// bootstrap/app.php (Laravel 11/12 style)
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php', // Opzionale, se non presente di default in L11+
        commands: __DIR__.'/../routes/console.php',
        health: '/up', // Health check route di default
    )
    ->withMiddleware(function (Middleware $middleware) {
        // Qui configureremo i middleware
    })
    ->withExceptions(function (Exceptions $exceptions) {
        // Qui configureremo la gestione delle eccezioni
    })
    ->withProviders([
        // App\Providers\FortifyServiceProvider::class, // Esempio provider custom/pacchetto
        // App\Providers\PennantServiceProvider::class, // Altro esempio
    ], true) // Il 'true' forza il caricamento di questi, altrimenti molti sono auto-scoperti
    ->create();

Nota: In Laravel 11+, i file routes/api.php e routes/channels.php non sono più presenti di default. Se la tua applicazione li usa, dovrai specificarli qui o installarli con php artisan install:api / php artisan install:broadcasting.

Passo 3: Migrazione della Configurazione del Routing

Le responsabilità del RouteServiceProvider (caricare i file di rotte, definire prefissi, middleware per gruppi, rate limiter) si spostano in bootstrap/app.php tramite il metodo withRouting().

Da app/Providers/RouteServiceProvider.php (Laravel 10): La logica nel metodo boot() e configureRateLimiting():

// In RouteServiceProvider (L10) - parte della logica da migrare
public function boot(): void
{
    $this->configureRateLimiting();

    $this->routes(function () {
        Route::middleware('api')
            ->prefix('api') // Prefisso standard, ma potrebbe essere custom
            ->name('api.') // Opzionale, per i nomi delle rotte
            ->group(base_path('routes/api.php'));

        Route::middleware('web')
            ->group(base_path('routes/web.php'));
    });

    // Esempio di configurazione Route Model Binding esplicito
    // Route::model('user', App\Models\User::class);
    // Oppure configurazioni di pattern globali per i parametri di rotta
    // Route::pattern('id', '[0-9]+');
}

protected function configureRateLimiting(): void
{
    RateLimiter::for('api', function (Request $request) {
        return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
    });
    // Altri rate limiter custom...
    RateLimiter::for('uploads', function (Request $request) {
        return Limit::perMinute(10)->by($request->user()?->id ?: $request->ip());
    });
}

A bootstrap/app.php (Laravel 11/12):

// bootstrap/app.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route; // Necessario se usi metodi di Route qui

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php', // Assumendo che tu lo usi
        apiPrefix: 'api/custom/v1', // Puoi cambiare il prefisso API qui
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
        // Il metodo 'then' viene eseguito DOPO che i file di rotte sono stati caricati
        // È un buon posto per configurare il route model binding esplicito o pattern globali
        then: function () {
            // Esempio: Route model binding esplicito (se ancora necessario)
            // Route::model('user', \App\Models\User::class);

            // Esempio: Pattern globale per parametri ID
            // Route::pattern('id', '[0-9]+');

            // Definisci qui i rate limiter
            RateLimiter::for('api', function (Request $request) {
                // La logica per 'api' ora usa il rate limiter definito nel middleware di throttle
                // assegnato al gruppo 'api', quindi questa definizione è più per rate limiter custom
                // richiamati esplicitamente con ->middleware('throttle:custom_limiter_name')
                return Limit::perMinute(1000)->by($request->user()?->id ?: $request->ip());
            });
            RateLimiter::for('uploads', function (Request $request) {
                return Limit::perMinute(10)->by($request->user()?->id ?: $request->ip());
            });
        }
    )
    // ... (withMiddleware, withExceptions) ...
    ->create();

Nota importante sul Rate Limiting API: In Laravel 11+, il middleware throttle:api applicato di default al gruppo api (se usi php artisan install:api) fa riferimento a un limiter chiamato api. La sua definizione di default (es. 60 richieste al minuto) è interna al framework. Se vuoi sovrascriverla o personalizzarla, puoi farlo nel then callback come mostrato, oppure definire limiter con nomi diversi e applicarli specificamente alle tue rotte o gruppi.

Passo 4: Migrazione della Configurazione dei Middleware

La registrazione dei middleware globali, dei gruppi e degli alias si sposta da app/Http/Kernel.php a bootstrap/app.php usando il metodo withMiddleware().

Da app/Http/Kernel.php (Laravel 10): Le proprietà $middleware, $middlewareGroups, $middlewareAliases (o $routeMiddleware).

A bootstrap/app.php (Laravel 11/12):

// bootstrap/app.php
use App\Http\Middleware\MyCustomGlobalMiddleware;
use App\Http\Middleware\EnsureUserIsAdmin;
use App\Http\Middleware\AnotherWebMiddleware;
use Illuminate\Foundation\Configuration\Middleware; // Importa la classe Middleware

return Application::configure(basePath: dirname(__DIR__))
    // ... (withRouting) ...
    ->withMiddleware(function (Middleware $middleware) {
        // Middleware Globali (equivalente di $middleware)
        // Vengono eseguiti per ogni richiesta HTTP all'applicazione.
        // Alcuni middleware precedentemente globali sono ora gestiti internamente dal framework.
        // Aggiungi solo quelli veramente custom e globali.
        $middleware->append(MyCustomGlobalMiddleware::class);

        // Alias dei Middleware (equivalente di $middlewareAliases o $routeMiddleware)
        $middleware->alias([
            'is_admin' => EnsureUserIsAdmin::class,
            'locale_redirect' => \App\Http\Middleware\LocaleRedirectMiddleware::class,
        ]);

        // Gruppi di Middleware (equivalente di $middlewareGroups)
        // Il gruppo 'web' è spesso già preconfigurato con middleware essenziali
        // (cookies, session, CSRF, etc.) dal framework stesso.
        // Puoi aggiungerne altri o sovrascrivere.
        $middleware->group('web', [
            AnotherWebMiddleware::class,
            // ... altri middleware specifici per il gruppo 'web'
        ]);

        // Il gruppo 'api' è anche preconfigurato se si usa `php artisan install:api`.
        // Tipicamente include 'throttle:api' e la gestione delle bindings.
        $middleware->group('api', [
            // \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, // Se usi Sanctum per SPA
            'throttle:api', // Farà riferimento al rate limiter 'api'
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ]);

        // Puoi anche aggiungere middleware specifici per i comandi console
        // $middleware->forCommands([
        //     \App\Console\Middleware\EnsureDatabaseIsReachable::class,
        // ]);

        // Per modificare la priorità dei middleware (se necessario, raramente)
        // $middleware->priority([
        //      \Illuminate\Cookie\Middleware\EncryptCookies::class,
        //      \Illuminate\Session\Middleware\StartSession::class,
        //      // ...
        // ]);
    })
    // ... (withExceptions) ...
    ->create();

Laravel 11+ gestisce internamente molti dei middleware che prima erano esplicitamente nel Kernel HTTP (es. EncryptCookies, StartSession, VerifyCsrfToken per il gruppo web). Devi solo aggiungere/configurare quelli specifici della tua applicazione.

Passo 5: Migrazione della Gestione delle Eccezioni

La personalizzazione del reporting e del rendering delle eccezioni si sposta da app/Exceptions/Handler.php a bootstrap/app.php usando il metodo withExceptions().

Da app/Exceptions/Handler.php (Laravel 10): I metodi register(), o le proprietà $dontReport, $dontFlash, e i metodi report() e render().

A bootstrap/app.php (Laravel 11/12):

// bootstrap/app.php
use Illuminate\Foundation\Configuration\Exceptions; // Importa la classe Exceptions
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use App\Exceptions\CustomApplicationException;
use Throwable;

return Application::configure(basePath: dirname(__DIR__))
    // ... (withRouting, withMiddleware) ...
    ->withExceptions(function (Exceptions $exceptions) {
        // Equivalente di $dontReport
        $exceptions->dontReport([
            CustomApplicationException::class,
        ]);

        // Equivalente di $reportable per report custom
        $exceptions->report(function (CustomApplicationException $e) {
            // Logica di reporting custom per questa eccezione
            Log::channel('custom_errors')->error("Custom App Error: " . $e->getMessage());
        });

        $exceptions->report(function (Throwable $e) {
            // Logica di reporting generica, se non gestita specificamente sopra
            if (app()->bound('sentry') && $this->shouldReport($e)) { // Esempio con Sentry
                // app('sentry')->captureException($e);
            }
        })->stop();// Il ->stop() previene il reporting di default di Laravel per questa Throwable

        // Equivalente di $renderable per rendering custom
        $exceptions->render(function (NotFoundHttpException $e, Request $request) {
            if ($request->is('api/*')) {
                return response()->json(['message' => 'Risorsa non trovata.'], 404);
            }
            // Altrimenti, lascia che Laravel gestisca la pagina 404 di default
        });

        $exceptions->render(function (CustomApplicationException $e, Request $request) {
            return response()->view('errors.custom_app_error', ['message' => $e->getMessage()], 500);
        });

        // Puoi anche definire livelli di log specifici per eccezione
        // $exceptions->level(InvalidOrderException::class, LogLevel::CRITICAL);

        // Per non inviare certe variabili al sistema di gestione errori (es. Ignition/Flare)
        // $exceptions->dontFlash(['password', 'credit_card_number']);
    })
    ->create();

Passo 6: Razionalizzazione dei Service Provider

Con la nuova struttura, molti service provider di default o quelli che servivano principalmente per registrare configurazioni di base (come RouteServiceProvider o AuthServiceProvider per le policy) potrebbero non essere più necessari nella loro forma estesa o possono essere eliminati se le loro funzionalità sono ora gestite in bootstrap/app.php o tramite autodiscovery.

  • RouteServiceProvider: le sue responsabilità principali (caricamento rotte, rate limiting) sono ora in bootstrap/app.php ->withRouting(). Può essere rimosso o svuotato se non ha altra logica custom.
  • AuthServiceProvider: l'autodiscovery delle policy di Laravel (se le policy sono nella directory app/Policies/ e seguono le convenzioni di nomenclatura) spesso elimina la necessità di registrarle manualmente. Se hai Gate custom o altre logiche, queste possono rimanere qui, o essere spostate in AppServiceProvider o registrate in bootstrap/app.php (es. Gate::define(...) nel callback then di withProviders o in un provider dedicato se la logica è molta).
  • Event Discovery: è abilitato di default, quindi EventServiceProvider potrebbe non essere necessario solo per mappare eventi e listener se segui le convenzioni.

Rivedi i tuoi service provider custom e quelli di default: se registrano solo configurazioni ora gestibili in bootstrap/app.php o coperte dall'autodiscovery, considera di semplificarli o rimuoverli.

Passo 7: Rimozione dei File Kernel e Provider Obsoleti

Una volta migrate le configurazioni, puoi eliminare in sicurezza:

  • app/Http/Kernel.php
  • app/Console/Kernel.php
  • app/Providers/RouteServiceProvider.php (se tutte le sue funzionalità sono state migrate)
  • Eventualmente altri provider resi obsoleti.

Ricorda di rimuoverli anche dalla sezione providers del tuo config/app.php se erano registrati lì e non sono più necessari (anche se con bootstrap/app.php ->withProviders() questo file di configurazione diventa meno centrale per la registrazione dei provider).

Passo 8: Testing Approfondito

Dopo queste modifiche strutturali, è cruciale eseguire la tua test suite completa per assicurarti che tutto funzioni come previsto: routing, middleware, gestione eccezioni, autorizzazione, ecc. Presta particolare attenzione ai test funzionali/HTTP.

Benefici della Nuova Struttura per le Applicazioni Aziendali

Adottare questa struttura snella per le tue applicazioni Laravel offre diversi vantaggi:

  • Maggiore Chiarezza: la configurazione di base dell'applicazione è centralizzata in un unico posto (bootstrap/app.php), rendendo più facile capire come l'applicazione è assemblata.
  • Riduzione del Boilerplate: meno file e meno codice di configurazione da mantenere.
  • Configurazione più Programmatica: l'API fluente offre un modo più espressivo e flessibile per definire la configurazione rispetto ai file di classe con proprietà.
  • Facilità di Onboarding: i nuovi sviluppatori che conoscono Laravel 11+ si troveranno immediatamente a loro agio.
  • Allineamento con il Futuro di Laravel: adottare le nuove convenzioni ti prepara meglio per le future evoluzioni del framework.

Il Ruolo del Consulente Laravel Esperto in Migrazioni Strutturali

Un refactoring di questa portata, sebbene Laravel fornisca un percorso chiaro, può presentare delle sfide in applicazioni aziendali grandi e complesse, con molta logica custom nei Kernel o nei Service Provider. Come senior laravel developer e consulente con una profonda conoscenza dell'architettura di Laravel attraverso le sue versioni, posso aiutare la tua impresa a:

  • Valutare la fattibilità e l'impatto del refactoring sul tuo specifico applicativo.
  • Pianificare ed eseguire la migrazione in modo sicuro ed efficiente.
  • Ristrutturare la logica custom per adattarla al nuovo paradigma.
  • Garantire che la test suite sia adeguata per validare le modifiche.
  • Formare il tuo team di sviluppo sulle nuove pratiche.

Puoi scoprire di più sulla mia esperienza e sul mio approccio consulenziale visitando la pagina Chi Sono.

Abbracciare l'evoluzione di Laravel non significa solo inseguire l'ultima versione, ma adottare pratiche che rendono le tue applicazioni più moderne, pulite e facili da mantenere nel tempo. La transizione alla nuova struttura applicativa è un passo importante in questa direzione.

Se la tua attività sta considerando di aggiornare una vecchia applicazione Laravel e vuoi assicurarti che il processo sia gestito con la massima competenza e attenzione ai dettagli, non esitare a contattarmi per una discussione approfondita.

Ultima modifica: Giovedì 13 Febbraio 2025, alle 11:18