Quando una query fallisce, Laravel costruisce una QueryException il cui messaggio contiene l'SQL con tutti i valori bindati interpolati dentro. Comodissimo in sviluppo. In produzione significa che ogni insert fallita copia i dati del cliente — email, nome, magari un codice fiscale — dentro i log, dentro la tabella failed_jobs e dentro le trace dell'APM. Laravel 13.27, uscito il 26 agosto, aggiunge finalmente un interruttore per spegnere questa interpolazione. Vale la pena capire cosa chiude davvero, e cosa no.
Il problema: le eccezioni vengono scritte da qualche parte
Il messaggio di una QueryException è pensato per essere letto da uno sviluppatore che sta debuggando: insert into users (email) values (?) non dice quale riga si è rotta, la versione con il valore sì. Il guaio è che quel messaggio non resta sullo schermo di chi debugga. È una stringa attaccata a un'eccezione, e le eccezioni vengono annotate ovunque:
- nel file di log o nel canale che avete configurato in
logging.php; - nella colonna
exceptiondifailed_jobs, perché il provider dei job falliti casta l'eccezione a stringa e la inserisce così com'è; - negli span dell'agente APM o OpenTelemetry, se ne usate uno;
- in qualunque servizio di error tracking verso cui il reporting spedisce dati.
Ognuno di questi posti diventa una copia non dichiarata dei dati che stavate scrivendo nel database. Copie con retention diverse, permessi di accesso diversi, e quasi mai censite quando si compila il registro dei trattamenti. In undici anni di gestionali Laravel ho visto log applicativi letti da molte più persone di quante avessero accesso al database: è normale, i log servono a quello. Il punto è cosa ci finisce dentro senza che nessuno l'abbia deciso.
Cosa fa Laravel 13.27
La 13.27 introduce l'opzione per connessione mask_bindings_in_exception_messages. La chiave è già presente nel config/database.php del framework su tutte e cinque le connessioni di default, quindi anche un'applicazione che non ha mai pubblicato quel file può attivarla con una sola variabile d'ambiente:
DB_MASK_BINDINGS=true
Con il flag attivo, il messaggio dell'eccezione mantiene i segnaposto:
SQLSTATE[23000]: Integrity constraint violation: 1062
Duplicate entry 'ada@example.com' for key 'users_email_unique'
(SQL: insert into `users` (`email`, `name`, `national_id`)
values (?, ?, ?))
Notate il dettaglio: il valore duplicato compare comunque nella prima riga, perché quella parte del messaggio arriva dal driver del database, non da Laravel. Il flag maschera l'interpolazione che fa il framework, non riscrive quello che dice MySQL. È già molto — l'insert completa con tre colonne di dati personali sparisce — ma non è una bonifica totale del messaggio.
Perché lo attiverei quasi sempre
Il costo del flag è che davanti a un'eccezione in produzione non vedete più i valori al volo: dovete recuperare i binding in altro modo, ad esempio loggandoli separatamente su un canale con retention corta e accesso ristretto, o riproducendo il caso in ambiente di sviluppo. È una frizione reale. Ma la domanda giusta non è "mi complica il debug?", è "chi legge i miei log, e per quanto tempo restano?". Se la risposta contiene un servizio di terze parti, un APM, o una retention di mesi, l'interpolazione dei binding è un trattamento di dati personali che non avete mai dichiarato a nessuno, nemmeno a voi stessi.
Il modello di minaccia è più ampio di un flag
Questa novità mi sta a cuore perché tocca un principio su cui ho costruito Miraviso, il mio SaaS per saloni di parrucchieri: i dati sensibili non vanno protetti solo dove pensate di conservarli, ma in tutti i posti dove l'infrastruttura li ricopia senza chiedere. Log, code, trace, backup dei log, snapshot: ogni componente che serializza stato per aiutarvi a debuggare è un potenziale archivio ombra.
In Miraviso le note sui clienti dei saloni possono contenere allergie e patologie del cuoio capelluto, e lì ho tirato la conclusione fino in fondo: quelle note sono cifrate con una chiave derivata sul dispositivo, e il server conserva buste che non può aprire. Ne ho scritto in dettaglio in un articolo dedicato. La proprietà interessante ai fini di questo pezzo è che il design a buste rende il problema dei log strutturalmente impossibile per quei campi: se il testo in chiaro non esiste mai lato server, nessuna eccezione, per quanto verbosa, può interpolarlo in un messaggio. Non c'è flag da ricordarsi di attivare, non c'è agente APM configurato male che possa tradirvi.
Non sto dicendo che ogni campo meriti quel trattamento — la stessa segmentazione che applico lì vale qui. La maggior parte dei dati può stare in chiaro lato server, e per quelli il flag di Laravel 13.27 è la mossa giusta: economica, immediata, reversibile. Un piccolo insieme di campi è così delicato che conviene renderne impossibile la fuga, non improbabile. Il criterio per distinguerli è sempre lo stesso: il server ha un bisogno legittimo di leggere questo valore? Se no, fate in modo che non possa.
Da fare oggi
Se avete un'applicazione Laravel in produzione che tratta dati personali, la lista è corta. Aggiornate alla 13.27 e attivate DB_MASK_BINDINGS almeno negli ambienti di produzione. Poi aprite failed_jobs e guardate cosa c'è nella colonna exception dei record vecchi: il flag protegge il futuro, non ripulisce il passato, e quei record hanno la retention che gli avete dato voi — spesso infinita. Infine chiedetevi quali altri componenti del vostro stack castano eccezioni a stringa e le conservano: è un esercizio meno divertente di scrivere risolutori di nonogrammi, ma rende il prossimo audit molto più breve.