Ottimizzare la performance di Laravel

Ottimizzare un’applicazione Laravel non significa solo “renderla più veloce”: vuol dire ridurre latenza, consumo di CPU e I/O, migliorare la prevedibilità sotto carico e abbassare i costi operativi. In questa guida trovi un percorso completo, dall’osservabilità alle ottimizzazioni applicative e infrastrutturali, con esempi concreti e checklist operative.

1. Misura prima di ottimizzare: osservabilità e baseline

Prima di intervenire, definisci una baseline riproducibile (stesso dataset, stesso carico, stessi endpoint) e misura con strumenti affidabili. Senza misure rischi di ottimizzare “a sensazione” e spostare i colli di bottiglia altrove.

Strumenti consigliati

  • Laravel Telescope (dev/staging): query, request, job, cache, eventi.
  • Laravel Debugbar (dev): panoramica immediata su query e view.
  • APM (production): New Relic, Datadog, Elastic APM, ecc. per tracce distribuite, SQL slow, error rate.
  • Database slow query log e EXPLAIN per analizzare piani di esecuzione.
  • Load testing: k6, JMeter, Locust, wrk, vegeta.

Metriche da tenere d’occhio

  • p50/p95/p99 (latenza), non solo media.
  • Throughput (req/s) e error rate.
  • Numero di query e tempo totale SQL per request.
  • Cache hit rate, latenza di Redis/Memcached.
  • CPU/memoria di PHP-FPM e del database.

2. Caching: la leva più potente (se usata bene)

Il caching riduce lavoro ripetitivo e sposta tempo da CPU/DB a memoria. Laravel offre più livelli: config/route/view cache, cache applicativa, cache HTTP e cache dei risultati SQL indirettamente via query cache applicativa.

2.1 Cache di configurazione, rotte e view

In produzione, abilita sempre le cache “build-time”.

php artisan config:cache
php artisan route:cache
php artisan view:cache

Nota: route:cache richiede che tutte le route siano serializzabili (niente closure nelle definizioni).

2.2 Cache applicativa con TTL e invalidazione

Usa Cache::remember() per memorizzare risultati costosi. Scegli una strategia di invalidazione: TTL breve, cache tagging (se supportato), o invalidazione esplicita su eventi di dominio.

use Illuminate\Support\Facades\Cache;

$stats = Cache::remember(
    "dashboard:stats:user:{$user->id}",
    now()->addMinutes(10),
    function () use ($user) {
        return [
            'orders' => $user->orders()->count(),
            'spent'  => $user->orders()->sum('total'),
        ];
    }
);

Se usi Redis, valuta i tag per invalidare gruppi correlati (attenzione ai driver che non li supportano).

Cache::tags(['dashboard', "user:{$user->id}"])
    ->remember('stats', 600, fn () => $this->computeStats($user));

// In invalidazione:
Cache::tags(['dashboard', "user:{$user->id}"])->flush();

2.3 Cache delle view e dei fragment

Per pagine con parti statiche o semi-statiche, usa cache a livello di view per blocchi specifici.

@php
    $key = "home:featured:v1";
@endphp

{!! Cache::remember($key, now()->addMinutes(30), function () {
    return view('partials.featured', ['items' => \App\Models\Item::featured()->limit(8)->get()])->render();
}) !!}

2.4 HTTP caching e CDN

Se servi contenuti pubblici, sfrutta Cache-Control, ETag o Last-Modified e metti una CDN davanti. Anche senza “full page cache”, una CDN riduce RTT e assorbe picchi.

3. Database: query efficienti, indici corretti, meno round-trip

Nella maggior parte dei casi, i veri colli di bottiglia sono nel database: query N+1, indici mancanti, join pesanti, aggregazioni su colonne non indicizzate, e troppi round-trip per request.

3.1 Elimina il problema N+1

Usa eager loading (with, load) e carica solo ciò che serve.

// Male: N+1 su author
$posts = Post::latest()->take(20)->get();
foreach ($posts as $post) {
    echo $post->author->name;
}

// Bene: eager loading
$posts = Post::with('author:id,name')
    ->latest()
    ->take(20)
    ->get();

foreach ($posts as $post) {
    echo $post->author->name;
}

3.2 Seleziona solo le colonne necessarie

Riduci I/O e memoria selezionando colonne specifiche, soprattutto su tabelle “wide”.

$users = User::query()
    ->select(['id', 'name', 'email'])
    ->where('active', true)
    ->orderBy('id', 'desc')
    ->paginate(50);

3.3 Indici: progettazione e verifica

Un indice giusto può trasformare una query da secondi a millisecondi. Regole pratiche:

  • Indicizza colonne usate in WHERE, JOIN, ORDER BY, GROUP BY.
  • Preferisci indici compositi che rispecchiano l’ordine dei filtri più selettivi.
  • Evita indici inutili: rallentano scritture e occupano spazio.
// Esempio migration con indice composito
Schema::table('orders', function (Blueprint $table) {
    $table->index(['user_id', 'status', 'created_at']);
});

Verifica sempre con EXPLAIN e log slow query. Se l’ottimizzatore non usa l’indice, controlla: cardinalità, tipo colonna, funzioni sul campo (es. DATE(created_at)), confronti con tipi diversi.

3.4 Aggregazioni e report: precomputazione e tabelle di supporto

Se calcoli spesso statistiche (es. dashboard), valuta:

  • Materializzare risultati in tabelle aggregate aggiornate via job.
  • Usare code/queue per ricalcoli asincroni.
  • Cache dei risultati per range temporali.

3.5 Pagination efficiente: keyset pagination

Per dataset grandi, OFFSET diventa costoso. Usa keyset pagination quando possibile.

$orders = Order::query()
    ->where('user_id', $userId)
    ->where('id', '<', $cursorId)
    ->orderByDesc('id')
    ->limit(50)
    ->get();

4. Eloquent e dominio: performance senza perdere leggibilità

Eloquent è comodo ma può generare query subottimali se usato senza attenzione. L’obiettivo non è “abbandonarlo”, ma usarlo con consapevolezza.

4.1 Evita accessors/mutators costosi in loop

Accessors che fanno query o calcoli pesanti possono moltiplicare i costi. Sposta logica in query (con aggregazioni) o pre-carica dati.

4.2 Usa chunk e cursor per grandi dataset

Per elaborazioni batch, evita di caricare tutto in memoria.

User::query()
    ->where('active', true)
    ->chunkById(1000, function ($users) {
        foreach ($users as $user) {
            // Processa
        }
    });
// Streaming (minimizza memoria), attenzione a connessioni lunghe
foreach (User::where('active', true)->cursor() as $user) {
    // Processa
}

4.3 Mass update/insert e upsert

Quando devi aggiornare molte righe, preferisci query set-based e upsert per ridurre round-trip.

DB::table('inventory')->upsert(
    [
        ['sku' => 'A1', 'qty' => 10],
        ['sku' => 'B2', 'qty' => 5],
    ],
    ['sku'],
    ['qty']
);

5. Code e background jobs: sposta lavoro fuori dalla request

La request HTTP dovrebbe fare il minimo necessario per rispondere. Tutto ciò che può essere asincrono va in coda: invio email, generazione report, elaborazioni immagini, chiamate a servizi esterni non critiche.

5.1 Pattern: “rispondi subito, completa dopo”

// Controller
dispatch(new \App\Jobs\GenerateReport($user->id));

return response()->json([
    'status' => 'accepted',
    'message' => 'Report in generazione'
], 202);

5.2 Configurazione e tuning dei worker

  • Separare code per tipologia (es. emails, reports, default).
  • Impostare timeout, tries, backoff e rate limiting.
  • Monitorare throughput, fallimenti e tempi di attesa (queue latency).

6. Eventi, listeners e notifiche: attenzione agli effetti collaterali

Eventi e listener sono ottimi per decoupling, ma possono introdurre lavoro nascosto dentro la request. Se un listener è pesante, rendilo queued.

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;

class SendWelcomeEmail implements ShouldQueue
{
    use InteractsWithQueue, Queueable, SerializesModels;

    public function handle(UserRegistered $event): void
    {
        // invio email o chiamata esterna
    }
}

7. PHP runtime e server: OPcache, PHP-FPM, e deployment

7.1 OPcache: indispensabile

OPcache riduce drasticamente il costo di parsing/compilazione PHP. In produzione deve essere attivo e correttamente dimensionato (memoria, max accelerated files, validate timestamps). In ambienti con deploy atomici, puoi disabilitare la validazione dei timestamp e fare reset su deploy.

7.2 PHP-FPM: processi, pm e timeouts

Bilancia pm.max_children in base a RAM disponibile e consumo medio per processo. Troppi children causano swap e peggiorano tutto; troppo pochi aumentano la coda in attesa. Misura memoria reale con carico tipico.

7.3 Autoloader ottimizzato e dependency trimming

composer install --no-dev --optimize-autoloader --classmap-authoritative

Rimuovi dipendenze inutili e servizi caricati senza motivo. Il “peso” del container e dei provider impatta il tempo di bootstrap.

8. Laravel Octane e Swoole/RoadRunner: quando ha senso

Octane può ridurre la latenza eliminando parte del bootstrap per request mantenendo l’app “warm”. Non è una bacchetta magica: richiede disciplina per evitare state leakage (stato condiviso tra richieste) e attenzione a servizi non “stateless”. È particolarmente utile per applicazioni con bootstrap pesante e molte request al secondo.

Checklist Octane

  • Evita singletons con stato mutabile non resettato.
  • Controlla cache in-memory e variabili statiche.
  • Usa test di carico e confronta p95/p99 prima/dopo.

9. Ottimizzazione delle view Blade e asset front-end

  • Usa view:cache in produzione.
  • Evita logica pesante nelle view: prepara i dati nel controller/service.
  • Minimizza asset (Vite), abilita HTTP/2 o HTTP/3 dove possibile.
  • Imposta caching aggressivo per asset versionati.

10. Riduci il numero di chiamate esterne e rendile resilienti

Le integrazioni (pagamenti, CRM, API terze) spesso dominano la latenza. Strategie:

  • Timeout brevi e retry controllati (con backoff).
  • Circuit breaker e fallback se il servizio è down.
  • Caching delle risposte quando possibile.
  • Spostare chiamate non critiche in coda.
use Illuminate\Support\Facades\Http;

$response = Http::timeout(2)
    ->retry(2, 200) // 2 retry, 200ms
    ->get($url);

if ($response->failed()) {
    // fallback/circuit breaker (es. cache, valore di default, ecc.)
}

11. Logging e debug in produzione: meno rumore, più segnale

Log eccessivi o troppo “verbatim” possono diventare un collo di bottiglia, specie su storage lento o su pipeline di log ingest. Imposta livelli adeguati, evita log in loop, e usa correlazione con request-id per tracciare problemi senza duplicare troppo.

12. Sicurezza e performance: rate limiting e protezioni mirate

Un attacco (anche involontario) può saturare risorse. Usa rate limiting per endpoint sensibili e proteggi operazioni costose. Laravel offre middleware e limitatori configurabili.

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

RateLimiter::for('login', function ($request) {
    return Limit::perMinute(10)->by($request->ip());
});

13. Checklist finale per un piano di ottimizzazione

  1. Definisci baseline e metriche (p95, error rate, query count).
  2. Identifica i top endpoint per traffico e latenza (APM).
  3. Elimina N+1, riduci colonne, aggiungi indici e verifica con EXPLAIN.
  4. Attiva config/route/view cache e autoloader ottimizzato.
  5. Implementa cache applicativa con invalidazione chiara.
  6. Sposta lavoro pesante in queue; misura queue latency e throughput.
  7. Tuning OPcache e PHP-FPM; rivedi timeouts e limiti.
  8. Ottimizza chiamate esterne (timeout, retry, caching, fallback).
  9. Test di carico e regressione: confronta p50/p95/p99 prima/dopo.
  10. Itera: ottimizzazione è un ciclo, non un’azione una tantum.

Conclusione

La performance in Laravel si migliora con un approccio sistematico: misura, individua il collo di bottiglia dominante, applica cambiamenti mirati e verifica l’impatto sulle metriche che contano (p95/p99, error rate, saturazione risorse). Con caching ben progettato, query ottimizzate e lavoro asincrono, la maggior parte delle applicazioni ottiene miglioramenti significativi senza sacrificare la qualità del codice.

Torna su