Come ottimizzare la performance del codice PHP

Ottimizzare un’applicazione PHP significa ridurre il tempo di risposta, migliorare il throughput e contenere l’uso di CPU e memoria, mantenendo il codice affidabile e manutenibile. In pratica si lavora su più livelli: runtime PHP e opcache, design del codice, I/O e database, caching, logging, rete e infrastruttura. Questo articolo raccoglie una checklist ragionata, con esempi e criteri pratici per misurare e intervenire senza “ottimizzazioni premature”.

1) Parti dai numeri: misurazione e profili

Prima di cambiare il codice, definisci una metrica (p95, p99, throughput, tempo di query, memoria) e misura in condizioni realistiche. La performance percepita spesso è dominata da poche “hot path”: un endpoint molto usato, una query lenta, un loop su molti record, un’operazione di serializzazione o I/O. Due regole:

  • Riproduci il problema in modo deterministico (stesso input, stesso dataset, stesso carico).
  • Ottimizza una cosa per volta e verifica con benchmark ripetibili.

In ambiente di sviluppo puoi aggiungere misurazioni leggere (timing e memoria) per individuare colli di bottiglia evidenti. Evita di lasciare misurazioni verbose in produzione, a meno che siano campionate e centralizzate.

<?php
$start = hrtime(true);
// ... codice da misurare ...
$elapsedMs = (hrtime(true) - $start) / 1e6;

$memNow = memory_get_usage(true);
$memPeak = memory_get_peak_usage(true);

error_log(sprintf(
    "elapsed=%.2fms mem=%dKB peak=%dKB",
    $elapsedMs,
    (int) ($memNow / 1024),
    (int) ($memPeak / 1024)
));

2) Abilita e verifica OPcache

OPcache è uno degli acceleratori più efficaci: conserva in memoria il bytecode compilato degli script PHP, evitando compilazioni ripetute a ogni richiesta. In produzione deve essere attivo e dimensionato correttamente. I problemi tipici sono:

  • Memoria OPcache insufficiente: il sistema “scarta” script e ricompila spesso.
  • Configurazioni di revalidate troppo conservative o troppo aggressive (in prod è comune disabilitare la validazione timestamp).
  • Warmup insufficiente dopo deploy: prime richieste più lente.

Un controllo veloce lato applicazione per capire se OPcache è attivo:

<?php
if (function_exists('opcache_get_status')) {
    $status = opcache_get_status(false);
    if (!is_array($status) || empty($status['opcache_enabled'])) {
        error_log("OPcache non attivo");
    }
}

3) Riduci l’I/O: filesystem, rete, serializzazione

L’I/O è spesso più lento della CPU. Ogni accesso a filesystem, rete o servizi esterni può dominare i tempi di risposta. Interventi ad alto impatto includono:

  • Evitare letture ripetute di file/config a ogni richiesta: precompila configurazioni e cache in memoria o in un cache store.
  • Batching delle richieste verso servizi esterni, timeouts ragionevoli, retries controllati.
  • Ridurre conversioni costose (JSON encode/decode, (de)serializzazione) dove possibile.

Un pattern comune è caricare più volte la stessa risorsa esterna nella stessa richiesta: centralizza l’accesso e memoizza in-request.

<?php
final class InRequestCache {
    private array $cache = [];

    public function remember(string $key, callable $compute): mixed {
        if (!array_key_exists($key, $this->cache)) {
            $this->cache[$key] = $compute();
        }
        return $this->cache[$key];
    }
}

// Uso:
$irc = new InRequestCache();
$user = $irc->remember("user:$id", fn() => $repo->findUserById($id));

4) Database: indici, query, N+1 e connessioni

Nella maggior parte delle applicazioni web, il database è il primo sospettato. Ottimizzare “a caso” sul codice PHP senza guardare query e piani di esecuzione spesso porta a guadagni minimi. I punti chiave:

4.1 Evita N+1 e usa query aggregate o eager loading

Il problema N+1 accade quando recuperi una lista e poi fai una query per ogni elemento. È devastante con dataset medi o grandi. Spesso si risolve con JOIN mirate, IN (...) e query aggregate.

<?php
// Anti-pattern: N+1
$posts = $db->query("SELECT id, title FROM posts WHERE published = 1")->fetchAll();
foreach ($posts as $p) {
    $comments = $db->prepare("SELECT COUNT(*) FROM comments WHERE post_id = ?");
    $comments->execute([$p['id']]);
    $p['comment_count'] = (int) $comments->fetchColumn();
}

// Pattern migliore: aggregazione
$sql = "
  SELECT p.id, p.title, COALESCE(c.cnt, 0) AS comment_count
  FROM posts p
  LEFT JOIN (
    SELECT post_id, COUNT(*) AS cnt
    FROM comments
    GROUP BY post_id
  ) c ON c.post_id = p.id
  WHERE p.published = 1
";
$posts = $db->query($sql)->fetchAll();

4.2 Indici e piani di esecuzione

Se una query filtra per colonne senza indice, il database può scansionare molte righe. Gli indici vanno progettati sui pattern reali: WHERE, JOIN, ORDER BY. Usa EXPLAIN/EXPLAIN ANALYZE per verificare. Un indice “giusto” può ridurre tempi di ordini di grandezza.

4.3 Prepared statements e riduzione del parsing

I prepared statements aiutano sicurezza e, in alcuni casi, performance: il DB può riutilizzare piani e ridurre parsing ripetuto, soprattutto con query ripetitive. Mantieni connessioni e statement riutilizzabili quando l’architettura lo consente.

<?php
$stmt = $db->prepare("SELECT id, email FROM users WHERE status = ? AND created_at >= ?");
$stmt->execute([$status, $fromDate]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);

5) Caching: strategia, livelli e invalidazione

Il caching è “moltiplicatore” di performance se applicato bene. La parte difficile non è mettere in cache, ma invalidare correttamente e prevenire dati incoerenti. Pensa a più livelli:

  • Cache in-request (memoization) per evitare calcoli ripetuti.
  • Cache applicativa (Redis/Memcached) per dati condivisi tra richieste.
  • HTTP caching e CDN (Cache-Control, ETag) per contenuti statici o semi-statici.

Una tecnica utile è il “cache-aside”: l’app prova a leggere dal cache store, se manca calcola e salva. Usa TTL e versioning delle chiavi per semplificare invalidazioni.

<?php
function cacheKey(string $base, int $version, array $parts = []): string {
    $suffix = $parts ? ':' . implode(':', $parts) : '';
    return $base . ':v' . $version . $suffix;
}

// Esempio: caching di un profilo utente
$key = cacheKey('user_profile', 3, [$userId]);

$profileJson = $redis->get($key);
if ($profileJson === false) {
    $profile = $repo->loadUserProfile($userId);      // query e join necessarie
    $profileJson = json_encode($profile, JSON_UNESCAPED_UNICODE);
    $redis->setex($key, 300, $profileJson);          // TTL 5 minuti
}
$profile = json_decode($profileJson, true, flags: JSON_THROW_ON_ERROR);

Attenzione al “thundering herd”: se molte richieste scadono insieme, tutte ricomputano. Soluzioni comuni: TTL con jitter, lock temporanei o “stale-while-revalidate” (servi dato vecchio per poco mentre rigeneri).

6) Algoritmi e strutture dati: l’ottimizzazione più pulita

Migliorare la complessità algoritmica spesso è più efficace di micro-ottimizzazioni. Alcuni esempi tipici in PHP:

  • Evitare in_array ripetuti su array grandi: converti a set usando una mappa.
  • Preferire foreach a array_map/array_filter quando serve anche evitare allocazioni intermedie.
  • Ridurre copie di array: attenzione a funzioni che duplicano dati e a passaggi non necessari.
<?php
// Anti-pattern: O(n*m) con in_array su lista grande
$allowed = $repo->loadAllowedIds(); // migliaia
$result = [];
foreach ($items as $it) {
    if (in_array($it['id'], $allowed, true)) {
        $result[] = $it;
    }
}

// Pattern migliore: set O(1) medio
$allowedSet = array_fill_keys($allowed, true);
$result = [];
foreach ($items as $it) {
    if (isset($allowedSet[$it['id']])) {
        $result[] = $it;
    }
}

7) Gestione della memoria e garbage collection

In PHP, la memoria conta soprattutto su processi long-running (worker di queue, websocket, cron lunghi) e in script che gestiscono grandi volumi di dati (export, import, ETL). Suggerimenti pratici:

  • Processa a stream o a chunk invece di caricare tutto in memoria.
  • Evita strutture dati annidate enormi se non servono: serializza progressivamente.
  • Nei worker, libera riferimenti a oggetti e array grandi una volta usati.
<?php
// Esempio: elaborazione a chunk per ridurre memoria
$offset = 0;
$limit = 1000;

while (true) {
    $rows = $repo->fetchRows($offset, $limit);
    if (!$rows) {
        break;
    }

    foreach ($rows as $row) {
        $processor->handle($row);
    }

    // libera riferimenti e avanza
    unset($rows);
    $offset += $limit;
}

8) Evita micro-ottimizzazioni inutili, ma cura gli hot spot

“Micro-ottimizzare” concatenazioni o piccoli dettagli raramente cambia il quadro, a meno che il punto sia eseguito milioni di volte. Concentrati su:

  • riduzione del numero di chiamate a funzioni/servizi esterni in hot path;
  • batching e caching;
  • query e indici;
  • algoritmi e strutture dati.

Un esempio frequente è fare parsing ripetuto di dati invarianti. Se un valore è costante per l’intero processo, calcolalo una volta sola e riutilizzalo.

<?php
final class Config {
    private static ?array $cached = null;

    public static function get(): array {
        if (self::$cached === null) {
            $path = __DIR__ . '/config.php';
            self::$cached = require $path;
        }
        return self::$cached;
    }
}

9) Framework e autoload: ottimizza senza perdere qualità

Molti progetti PHP usano Composer e un framework. Qui l’obiettivo è ridurre il lavoro “automatico” che non porta valore nella richiesta:

  • Composer autoload ottimizzato in produzione (classmap autoritativa quando appropriato).
  • Ridurre service provider/listener non necessari e lazy-load dei servizi pesanti.
  • Cache della configurazione e delle route se il framework lo supporta.

In generale: più “magia” equivale a più lavoro a runtime. Usa la magia dove aumenta produttività, ma misura gli impatti su richieste critiche.

10) Logging, error handling e osservabilità

Logging e gestione errori influenzano la performance, specie se si logga troppo o si scrive su disco sincrono. Linee guida:

  • Log strutturati e campionati per eventi ad alto volume.
  • Evita stringhe enormi o serializzazioni profonde in log.
  • Usa livelli corretti (debug in produzione solo se necessario e con sampling).
<?php
// Logging strutturato con contesto leggero
$logger->info('checkout_completed', [
    'user_id' => $userId,
    'order_id' => $orderId,
    'elapsed_ms' => $elapsedMs,
]);

11) Ottimizzazioni lato HTTP: compressione, payload e caching

Anche se il server risponde veloce, payload grandi e assenza di caching possono rallentare l’esperienza. Le ottimizzazioni tipiche includono:

  • Ridurre dimensioni JSON (campi necessari, paginazione, compressione a livello web server).
  • ETag/Last-Modified quando il contenuto è riutilizzabile.
  • Cache-Control con TTL adeguati e invalidazioni via versioning degli asset.
<?php
// Esempio: risposta JSON con header di caching
$data = ['items' => $items, 'next' => $nextCursor];
$body = json_encode($data, JSON_UNESCAPED_UNICODE);

header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: public, max-age=60'); // 60s
echo $body;

12) Checklist finale: priorità e percorso tipico

  1. Misura: definisci metriche e riproduci (p95/p99, query time, memoria).
  2. OPcache: attivo e dimensionato.
  3. Database: elimina N+1, aggiungi indici, riduci round-trip, verifica piani.
  4. Caching: applica cache-aside, TTL e versioning; proteggi da thundering herd.
  5. I/O: riduci chiamate a servizi e filesystem; batching e timeouts.
  6. Algoritmi: passa da O(n*m) a O(n) dove possibile; evita strutture inutili.
  7. Memoria: chunking/streaming; worker puliti senza leak.
  8. HTTP: riduci payload, usa caching e compressione.

Se applichi questi passi nell’ordine giusto, di solito ottieni miglioramenti significativi senza compromettere chiarezza e qualità. La chiave è sempre la stessa: misurare, cambiare poco e verificare.

Torna su