Analisi statica del codice PHP con Psalm e PHPStan: integrazione in pipeline CI/CD

Analisi statica del codice PHP con Psalm e PHPStan: integrazione in pipeline CI/CD

A giugno 2025 ho iniziato un audit strutturato su un'applicazione Laravel 9 di un cliente del settore distribuzione industriale, PMI italiana con circa 70 dipendenti e fatturato annuo vicino ai 14 milioni di euro. Il gestionale era stato sviluppato organicamente in sette anni da un team interno di tre developer, con scarsa copertura di test (circa 12% di coverage dichiarato, ma in realtà meno del 5% sulle business logic critiche) e zero analisi statica attiva nella pipeline di deploy. La richiesta iniziale del cliente era una pentest applicativa classica, ma prima ancora di accendere Burp Suite ho proposto una fase preliminare più economica: tre giorni di introduzione di PHPStan a livello 9 sulla codebase esistente per far emergere i bug di tipo che sarebbero stati la base di partenza dei futuri exploit. Il cliente ha accettato malvolentieri ("ma se il codice funziona in produzione, perché aggiungere strumenti?"). Al terzo giorno di lavoro PHPStan aveva prodotto 340 errori di tipo, il cliente aveva cambiato idea sulla priorità del lavoro, e nelle successive sei settimane abbiamo portato il codice al livello 9 completo con zero errori, sistemando durante il percorso 12 problemi che erano vere vulnerabilità di sicurezza. Due erano potenziali SQL injection in query raw dove un parametro int non validato veniva interpolato in una stringa SQL; uno era un IDOR chiaro su un endpoint API dove la Policy di autorizzazione era dichiarata nel Route ma non effettivamente eseguita nel controller; gli altri nove erano problemi di minore severity ma tutti sfruttabili in specifiche condizioni.

Il punto che il cliente non aveva previsto è che la pentest che è seguita - quattro settimane dopo, con PHPStan livello 9 stabilizzato nella pipeline CI - ha trovato solo altre tre vulnerabilità di criticità media, contro le venti-venticinque che avrei tipicamente trovato su una codebase Laravel simile non analizzata. L'analisi statica aveva agito da filtro preventivo: il 70% delle vulnerabilità che altrimenti avrei trovato in pentest erano state catturate prima, a costo marginale quasi nullo dopo il setup iniziale. Questo articolo descrive in dettaglio come introduco analisi statica rigorosa su progetti Laravel e Symfony in produzione, scegliendo fra Psalm e PHPStan, calibrando i livelli di severità, gestendo la migrazione incrementale su codebase legacy senza bloccare il flusso di sviluppo, e integrando il tool nel CI/CD come gate non negoziabile per il merge.

PHPStan o Psalm: la scelta che dipende meno dalle feature e più dal team

La domanda "PHPStan o Psalm?" è la prima che mi fanno ogni volta, e la risposta onesta è che tecnicamente sono entrambi eccellenti e le differenze di feature si sono ridotte drasticamente negli ultimi due anni. PHPStan, sviluppato e mantenuto da Ondřej Mirtes come progetto open source con documentazione ufficiale completa su phpstan.org, ha oggi un ecosistema di extension più ricco (Larastan per Laravel, PHPStan Doctrine extension, PHPStan Symfony extension) e una community più ampia che si traduce in più risposte su Stack Overflow e issue risolte più rapidamente. Psalm, sviluppato originariamente da Matt Brown presso Vimeo e documentato ufficialmente su psalm.dev, ha storicamente avuto un supporto più avanzato per generics prima che PHP li supportasse nativamente e una capacità di detection più raffinata su pattern taint analysis per la sicurezza applicativa.

La scelta concreta che faccio nei progetti dipende quasi sempre da due fattori pragmatici, non dalle feature. Primo: se il progetto usa Laravel e ha un ecosistema che ruota attorno a pacchetti Spatie, nWidart, barryvdh o simili, PHPStan+Larastan è la scelta canonica perché la maggior parte di questi pacchetti pubblica stub types compatibili con PHPStan e non sempre con Psalm. Secondo: se il team è già abituato al linguaggio degli errori di PHPStan (che restituisce messaggi tipo "Parameter #1 $userId of method X::show() expects int, string given"), non ha senso cambiare. Se il team è nuovo ad analisi statica e ha l'opportunità di scegliere, Psalm ha messaggi di errore spesso più intelligibili ai newcomer e un approccio di "taint analysis" che fa emergere le vulnerabilità di sicurezza in modo più esplicito.

Sul cliente distribuzione industriale ho scelto PHPStan con Larastan perché la codebase era satura di helper e convenzioni Laravel, e le extension Larastan permettevano di gestire i facade, i model factory, i container binding e le macro custom senza dover scrivere stub manuali. La decisione si è rivelata corretta: durante le sei settimane di lavoro, sono stato autosufficiente su tutti gli errori "Laravel-specifici" perché Larastan li conosceva, e non ho dovuto scrivere una singola definizione manuale di tipi per facade dinamici.

Il livello di severità e la strategia di migrazione incrementale

Il secondo errore più comune che vedo fare nei team che adottano analisi statica è partire direttamente al livello massimo (9 per PHPStan, errorLevel: 1 per Psalm) sulla codebase esistente, vedere migliaia di errori, frustrarsi, ridurre il livello a 1 o 2 per "poter lavorare", e trasformare lo strumento in decorazione inutile. Il pattern corretto è diverso: si parte dal livello più alto possibile, si genera una baseline che ignora gli errori esistenti, e si alza progressivamente la qualità bloccando solo i nuovi errori introdotti dopo l'adozione.

PHPStan supporta questo pattern nativamente con il file phpstan-baseline.neon generato da vendor/bin/phpstan analyse --generate-baseline. Il file contiene la lista degli errori correnti, che vengono ignorati nei successivi run. Qualunque nuovo errore introdotto da nuovo codice viene invece bloccato - e il team lo vede emergere immediatamente nella CI. Il risultato è che da giorno uno si impone una "no new errors" policy, e il debito tecnico di analisi si erode progressivamente a mano a mano che i file esistenti vengono toccati per motivi funzionali.

La configurazione minimale che installo come baseline nel phpstan.neon del progetto è questa:

# phpstan.neon - configurazione baseline Laravel
includes:
    - ./vendor/nunomaduro/larastan/extension.neon
    - ./phpstan-baseline.neon

parameters:
    level: 9
    paths:
        - app/
        - config/
        - database/
        - routes/
    excludePaths:
        - ./database/migrations/*_legacy_*.php
        - ./app/Http/Controllers/Legacy/*
    tmpDir: ./.phpstan-cache
    parallel:
        maximumNumberOfProcesses: 4
    checkMissingIterableValueType: true
    checkGenericClassInNonGenericObjectType: true
    reportUnmatchedIgnoredErrors: true

Tre scelte meritano attenzione. Prima: il livello 9 sin dal giorno uno, anche se la codebase ha migliaia di errori, perché il baseline li parcheggia. Seconda: excludePaths con wildcard per le parti veramente legacy che il team ha deciso di non toccare prima di una riscrittura pianificata - una scelta pragmatica, non un'ammissione di sconfitta. Sul cliente distribuzione industriale abbiamo escluso un modulo di reporting in PHP plain (non Laravel) che sarebbe stato riscritto nei tre mesi successivi; inutile perder tempo a annotare tipi su codice destinato alla rottamazione. Terza: reportUnmatchedIgnoredErrors: true forza la baseline a restare pulita - se un errore nella baseline viene corretto nel codice, PHPStan segnala che l'entry della baseline non matcha più niente e va rimossa. Senza questo flag, la baseline si incrostra progressivamente di ignore che non servono più.

La strategia di "erosione della baseline" si materializza con una convenzione di sviluppo: ogni volta che un developer tocca un file presente nella baseline per qualunque ragione (feature, bug fix, refactoring), deve anche sistemare gli errori PHPStan in quel file e rimuovere le relative entry dalla baseline. Dopo sei settimane sul cliente distribuzione industriale, abbiamo rimosso 340 entry su 340 dalla baseline - zero errori PHPStan a livello 9 su tutta la codebase. La chiave del successo è che nessun developer ha mai dovuto "fermare le feature per sistemare analisi statica"; l'erosione è avvenuta come side effect del lavoro funzionale normale.

Stai cercando un Consulente Informatico esperto per introdurre analisi statica rigorosa su una codebase PHP legacy senza bloccare il flusso di sviluppo del team, e per integrare il tool come gate di qualità nella pipeline CI/CD? Nel mio profilo professionale trovi l'esperienza concreta su PHPStan, Psalm, pipeline GitHub/GitLab e audit di sicurezza applicativa.

Le vulnerabilità di sicurezza che emergono dall'analisi dei tipi

Il motivo per cui l'analisi statica è uno strumento di sicurezza prima ancora che di qualità del codice è che molte categorie di vulnerabilità si manifestano come violazioni di tipo. Le quattro classi che ho visto emergere più frequentemente su codebase PHP legacy sono interessanti da discutere nel dettaglio.

La prima è la classe delle SQL injection in query raw o semi-raw. Il pattern che l'analisi statica cattura è: un parametro dichiarato nel controller come mixed o senza tipo (perché proviene direttamente da $request->input()) viene passato a una funzione che lo interpola in una stringa SQL. Sul cliente distribuzione industriale, il codice incriminato era questo:

// app/Http/Controllers/ReportController.php
// Versione originale con SQL injection potenziale
public function byCategoria(Request $request)
{
    $categoria = $request->input('categoria');
    $results = DB::select(
        "SELECT * FROM prodotti WHERE categoria_id = $categoria"
    );
    return response()->json($results);
}

PHPStan a livello 9 non rileva direttamente la SQL injection come regola esplicita, ma rileva che $request->input('categoria') ritorna mixed e che quel mixed viene concatenato in una stringa - il warning è "Parameter #1 of function DB::select expects string, string|concatenated with mixed given". Seguire il filo di quel warning porta immediatamente a rendersi conto che $categoria non è validato e non è un int come l'SQL si aspetta. La fix è doppia: validazione esplicita del tipo con $request->integer('categoria') e uso di prepared statement:

// Versione corretta post-PHPStan
public function byCategoria(Request $request)
{
    $categoria = $request->integer('categoria');
    $results = DB::select(
        'SELECT * FROM prodotti WHERE categoria_id = ?',
        [$categoria]
    );
    return response()->json($results);
}

La seconda classe sono gli IDOR che si manifestano come accessi a proprietà di oggetti potenzialmente null. Il pattern: un controller prende un {id} dalla route, fa Model::find($id) (che ritorna Model|null), e accede alle proprietà senza verificare se il risultato è null - oppure verifica solo la null-ness ma non la proprietà dell'oggetto rispetto all'utente autenticato. PHPStan segnala il primo problema al livello 8 con messaggio "Property access on null", e la fix spesso mette in luce il secondo problema (l'assenza di controllo di autorizzazione). Sul cliente distribuzione industriale questo pattern ha portato a individuare un endpoint /api/ordini/{id} dove qualunque utente autenticato poteva leggere ordini di qualunque altro utente - un IDOR classico mascherato da semplice null-check mancante.

La terza classe sono le XSS via attributi non type-safe. Laravel ha un meccanismo di escape by default nel Blade ({{ $var }} è automaticamente escaped), ma {!! $var !!} (raw output) viene usato spesso per html trusted. Psalm con taint analysis è migliore di PHPStan nel tracciare quando un dato user-provided arriva a un raw output senza passaggio di sanitizzazione; PHPStan lo cattura solo indirettamente attraverso il tipo. Ho descritto in dettaglio il pattern di audit di sicurezza per applicazioni PHP legacy con le vulnerabilità tipiche e le remediation pratiche in un altro approfondimento dedicato.

La quarta classe sono gli errori di deserializzazione unsafe. PHP unserialize() su dati user-provided è una RCE in attesa di accadere, e il pattern "unserialize di un valore che non è tipizzato" emerge immediatamente in PHPStan. Sul cliente distribuzione industriale c'era esattamente uno di questi punti in un modulo di gestione sessioni custom scritto otto anni fa che nessuno ricordava esistesse - è stato rimosso in favore del session handler standard di Laravel, chiudendo anche un vector di attacco storico che non era mai stato exploited ma che era stato aperto per anni.

Integrazione CI/CD: il gate che rende l'analisi statica disciplinata

L'analisi statica senza integrazione nella CI/CD è uno strumento di decoration che il team dimentica in tre settimane. L'analisi statica nella CI/CD come gate obbligatorio per il merge è un meccanismo che trasforma il comportamento del team in pochi giorni.

La configurazione GitHub Actions che installo come baseline è questa, progettata per bloccare il merge di PR che introducono nuovi errori senza rallentare troppo la pipeline:

# .github/workflows/static-analysis.yml
name: Static Analysis
on:
    pull_request:
    push:
        branches: [main]

jobs:
    phpstan:
        runs-on: ubuntu-latest
        steps:
            - uses: actions/checkout@v4
            - name: Setup PHP
              uses: shivammathur/setup-php@v2
              with:
                  php-version: "8.2"
                  tools: composer:v2
                  coverage: none
            - name: Restore Composer cache
              uses: actions/cache@v4
              with:
                  path: ~/.composer/cache
                  key: composer-${{ hashFiles('composer.lock') }}
            - name: Install dependencies
              run: composer install --prefer-dist --no-interaction --no-progress
            - name: Restore PHPStan cache
              uses: actions/cache@v4
              with:
                  path: .phpstan-cache
                  key: phpstan-${{ github.ref }}-${{ github.sha }}
                  restore-keys: |
                      phpstan-${{ github.ref }}-
                      phpstan-
            - name: Run PHPStan analysis
              run: vendor/bin/phpstan analyse --memory-limit=2G --error-format=github

Due dettagli importanti. Primo: la cache di PHPStan (tmpDir punta a .phpstan-cache) è condivisa tra run della stessa branch e persistita tra push, il che riduce il tempo di analisi dal minuto iniziale a 5-15 secondi sulle PR successive che toccano pochi file. Senza caching, PHPStan analizza tutto da capo ogni volta e diventa collo di bottiglia. Secondo: --error-format=github fa sì che GitHub annoti direttamente i punti del diff con gli errori PHPStan, rendendo la review più efficiente - il developer vede l'errore nel contesto della modifica, non in un log di output separato.

La stessa logica si applica a GitLab CI e a Bitbucket Pipelines con piccole varianti di sintassi. L'integrazione con Renovate per aggiornamento delle dipendenze - descritta in dettaglio nel mio articolo sull'aggiornamento automatico delle dipendenze PHP con Dependabot e Renovate - chiude il cerchio: Renovate apre PR per aggiornare i pacchetti, la CI gira PHPStan contro la nuova versione, e se gli stub types del pacchetto aggiornato hanno rivelato nuovi errori (succede, occasionalmente, quando una libreria corregge i propri tipi interni), il merge viene bloccato finché il team non gestisce il nuovo debito di analisi.

Metriche di beneficio misurate a sei mesi

Tengo metriche strutturate sui progetti dei miei clienti per quantificare il beneficio dell'analisi statica oltre il singolo intervento. Sul cliente distribuzione industriale, i sei mesi successivi al completamento dell'adozione PHPStan livello 9 hanno prodotto questi numeri: il numero di bug di tipo che arrivavano in produzione (tracciato attraverso i Sentry alert con category "TypeError" o "Fatal error") è sceso da una media di 4-6 al mese a una media di 0-1 al mese, un calo dell'80%. Il numero di incident di sicurezza rilevati dai clienti o dai loro penetration test esterni è sceso da 5 l'anno precedente a 1 nell'anno successivo (la vulnerabilità rimasta era una XSS stored che PHPStan non poteva catturare perché non era una violazione di tipo). Il tempo medio di onboarding di nuovi developer sulla codebase è sceso da 3 settimane a 1,5 settimane, perché PHPStan funge da documentazione vivente dei contratti fra classi e un nuovo developer riceve feedback immediato quando scrive codice non coerente con le convenzioni esistenti.

Un'osservazione secondaria, ma importante per chi gestisce un team: il morale del team è migliorato misurabilmente. Il motivo, nelle parole di uno degli sviluppatori interni del cliente: "prima, quando modificavo qualcosa nel modulo fatturazione, passavo due giorni a testare perché non sapevo cosa avrei potuto rompere. Ora PHPStan mi dice immediatamente se il mio cambio ha impatti su altre parti del codice, e posso mergere in un'ora con fiducia". L'analisi statica non sostituisce il test - i test servono per verificare comportamento, PHPStan verifica tipi - ma riduce enormemente la paura di toccare codice legacy, che è uno dei principali driver di debito tecnico incrementale. Ho discusso questo aspetto in profondità nel mio approfondimento sulle sei abitudini di un senior developer e sul rapporto 10:1 fra lettura e scrittura di codice nelle code review PMI - l'analisi statica è la leva tecnica che abilita la lettura del codice con confidenza e quindi il refactoring incrementale.

Se gestisci un progetto Laravel o Symfony in produzione senza analisi statica attiva, oppure hai PHPStan configurato a livello 1-2 "per non bloccare il lavoro" e sospetti che questo stia nascondendo problemi strutturali, il primo passo pragmatico è misurare: esegui vendor/bin/phpstan analyse --level=9 --memory-limit=2G --no-progress contro la tua codebase stanotte e guarda il numero di errori restituiti. Se sono meno di 100, il salto al livello 9 è realistico in due settimane di lavoro. Se sono fra 100 e 1.000, è fattibile in 4-8 settimane con la strategia di baseline. Se sono oltre 1.000, probabilmente la tua codebase ha problemi strutturali più profondi che l'analisi statica evidenzia ma non risolve da sola. Se vuoi una valutazione calibrata sul tuo specifico caso, con un piano di adozione dell'analisi statica integrato nel tuo processo di sviluppo e nella tua pipeline CI/CD, contattami per una consulenza: in due giornate di lavoro eseguo l'analisi iniziale, identifico le tre categorie di errori più impattanti per il tuo progetto, e ti consegno un piano di adozione incrementale con stime di effort e milestone verificabili trimestre per trimestre.

Ultima modifica: