Structured output validation di LLM in PHP: schemi JSON, fail-safe e difesa da hallucination in produzione
Il 18 gennaio 2026 nella mia pipeline personale una chiamata Claude API di estrazione dati da fatture elettroniche XML ha restituito questo output dove mi aspettavo un JSON strutturato: {"fornitore": "ACME SRL", "imponibile": "1200,00 euro", "iva": null, "scadenza": "prossima settimana"}. Il campo imponibile era stringa invece di numero, con virgola decimale e unità incorporata; iva era null invece di 22; scadenza era una frase discorsiva invece di una data ISO. La pipeline a valle - un job Laravel che scriveva su database con prepared statement tipati - è esplosa immediatamente, il queue worker è andato in retry loop, e dopo tre tentativi sul record sbagliato ha bloccato l'intera coda dei successivi 47 documenti in attesa. È stato un incident di due ore nella mia sandbox di test, non un incident cliente, ma la lezione ingegneristica è stata secca: un LLM in produzione che restituisce testo libero è una bomba a orologeria. La difesa non è migliorare il prompt - è strutturare l'output con uno schema validato rigorosamente, accettando a monte che l'LLM produrrà output malformato e progettando il sistema per assorbirlo senza cascate. OWASP nella Top 10 per LLM Applications 2025 classifica questa classe di problemi come LLM05 Improper Output Handling, e la pratica di difesa è oggi matura abbastanza da permettere un confronto sistematico tra gli approcci disponibili in PHP.
Perché l'output LLM non è mai affidabile senza validazione a schema?
I modelli LLM moderni hanno tassi di aderenza a istruzioni di formato che superano il 95% sui casi nominali ma non sono deterministici. Un output JSON prodotto da Claude o GPT è molto probabilmente ben formato, non certamente ben formato. Il 5% residuo di casi non-conformi si concentra statisticamente su input ambigui, su record con edge case, su sessioni dove il context si è saturato, su cambi di versione modello mid-deployment. Per un batch che processa 10.000 documenti al giorno, il 5% non-conforme sono 500 errori quotidiani. Senza strutturazione, ognuno di questi 500 è un incidente operativo potenziale.
La difesa ingegneristica è una pipeline difensiva multi-livello: schema rigido dichiarato in fase di request (i provider moderni supportano structured output nativo), parsing validation immediato al ricevuto, retry con correzione automatica sul solo record fallito, fallback a logica deterministica per i casi irrecuperabili, dead letter queue per i record che restano bloccati dopo tutti i tentativi. Ognuno dei livelli copre un diverso failure mode dell'LLM: lo schema previene hallucination di campi; la validation cattura malformazioni strutturali; il retry gestisce gli errori transitori; il fallback mantiene il SLA operativo; il dead letter preserva il dato problematico per analisi offline.
Confronto dei quattro approcci mainstream in PHP
I quattro approcci che ho testato nella mia sandbox hanno caratteristiche diverse per rigidità, integrazione framework, overhead operativo:
| Approccio | Rigidità schema | Integrazione Laravel | Integrazione Symfony | Overhead dev | Note operative |
|---|---|---|---|---|---|
| JSON Schema puro | Molto alta | Via libreria esterna | Via libreria esterna | Medio | Standard ufficiale, portabile cross-stack |
| DTO con attributi PHP | Alta | Buona (Spatie/laravel-data) | Eccellente (Symfony Validator) | Basso | Idiomatico PHP 8.x, type-safe |
| Form Request / ValueObject | Media | Eccellente | N/A | Bassissimo | Riuso dell'esistente framework |
| Tool Use API-side | Altissima | N/A framework-agnostic | N/A framework-agnostic | Alto | Validazione sul lato API provider |
La scelta tra questi non è questione di "quale è il migliore" - è questione di quale si integra meglio con il resto del tuo stack e con la skill del team che manterrà il codice. Vediamo i quattro in dettaglio con esempi operativi.
Approccio 1: JSON Schema puro con opis/json-schema
JSON Schema è lo standard universale per descrivere la forma di documenti JSON. In PHP la libreria che uso è opis/json-schema - maintainata, performante, supporta JSON Schema draft 2020-12. L'output LLM viene validato contro uno schema dichiarato a parte:
use Opis\JsonSchema\Validator;
use Opis\JsonSchema\Errors\ErrorFormatter;
$schema = json_decode(file_get_contents('schemas/invoice-extraction.json'));
$validator = new Validator();
$result = $validator->validate($llmOutput, $schema);
if (!$result->isValid()) {
$errors = (new ErrorFormatter())->format($result->error());
throw new StructuredOutputException('LLM output non conforme a schema', $errors);
}Il pregio di questo approccio è la portabilità: lo stesso schema può essere usato lato TypeScript frontend, lato Python in un altro servizio, passato come response_schema in alcune API LLM. Il difetto è che il JSON Schema è verboso e diventa rapidamente ingestibile per documenti complessi. Per la mia pipeline di estrazione fatture lo schema è lungo 180 righe; per casi più articolati può arrivare facilmente a 500+. La manutenzione richiede disciplina: ogni modifica deve essere versionata, testata con fixture JSON di esempio, e propagata al prompt LLM che lo include come istruzione.
Approccio 2: DTO con attributi PHP 8.x
Un approccio più idiomatico per uno sviluppatore PHP moderno è dichiarare la struttura attesa come classe PHP con attributi di validazione. Symfony Validator supporta questa via nativamente dai PHP 8 attributes:
use Symfony\Component\Validator\Constraints as Assert;
final class InvoiceExtraction
{
public function __construct(
#[Assert\NotBlank, Assert\Length(max: 200)]
public readonly string $fornitore,
#[Assert\Type('float'), Assert\Positive]
public readonly float $imponibile,
#[Assert\Type('float'), Assert\GreaterThanOrEqual(0), Assert\LessThanOrEqual(100)]
public readonly float $iva,
#[Assert\Date]
public readonly string $scadenza,
) {}
}Su Laravel un approccio equivalente è spatie/laravel-data: stessa filosofia, integrazione più stretta con Eloquent e con i Request validator. Il pregio qui è che il codice è self-documenting e type-safe; il difetto è che il contract è espresso in PHP invece che in uno standard portabile, quindi il prompt LLM deve includere la descrizione a parte.
La pipeline di validazione diventa:
$decoded = json_decode($llmOutput, true, flags: JSON_THROW_ON_ERROR);
$dto = new InvoiceExtraction(
fornitore: $decoded['fornitore'] ?? '',
imponibile: (float) ($decoded['imponibile'] ?? 0),
iva: (float) ($decoded['iva'] ?? 0),
scadenza: $decoded['scadenza'] ?? '',
);
$violations = $validator->validate($dto);
if (count($violations) > 0) {
throw new StructuredOutputException('LLM output non conforme', $violations);
}Nota che il casting esplicito prima della validazione è non negoziabile: se l'LLM restituisce "imponibile": "1200,00", il casting a float produce 1200.0 scartando la virgola - comportamento italiano tipico ma non sempre desiderato. Se la tua logica business richiede che imponibile sia sempre un numero puro, devi rifiutare string e forzare un retry.
Approccio 3: Form Request e ValueObject framework-native
Su Laravel il pattern nativo che tende a essere più economico da scrivere è usare FormRequest o un ValueObject custom che wrappi l'output LLM come se fosse un input utente. L'output LLM viene trattato come un request body qualsiasi, e passa attraverso le stesse regole di validazione che proteggerebbero un endpoint HTTP pubblico:
use Illuminate\Validation\Rule;
Validator::make($llmOutput, [
'fornitore' => ['required', 'string', 'max:200'],
'imponibile' => ['required', 'numeric', 'min:0'],
'iva' => ['required', 'numeric', 'between:0,100'],
'scadenza' => ['required', 'date_format:Y-m-d'],
])->validate();Il pregio enorme di questo approccio è il riuso: il tuo team già conosce le Rule Laravel, i messaggi di errore sono localizzati, l'integrazione con la coda dei Job è nativa. Il difetto è che questo pattern lavora bene su strutture flat e media complessità, ma si comporta male su strutture profondamente annidate (array di oggetti, poliformismo). Per pipeline semplici lo uso regolarmente; per pipeline complesse passo al JSON Schema puro.
Se stai costruendo una pipeline LLM in produzione e vuoi una metodologia applicata per scegliere il livello di rigidità giusto per il tuo caso, nel mio hub dedicato all'automazione AI per aziende trovo raccolti gli articoli tecnici con metodologia e perimetro che uso nei progetti di consulenza.
Approccio 4: structured output API-side con tool use
L'approccio più recente, matura tra la fine del 2024 e il 2025 e oggi universalmente disponibile sia in Anthropic API che in OpenAI, è delegare la validazione direttamente al provider LLM. Su Claude API si dichiara uno tool con input schema rigoroso, e il modello è forzato dal provider a produrre output conforme:
$response = $client->messages()->create([
'model' => 'claude-sonnet-4-6',
'tools' => [[
'name' => 'extract_invoice',
'description' => 'Extracts structured data from Italian invoice XML',
'input_schema' => [
'type' => 'object',
'required' => ['fornitore', 'imponibile', 'iva', 'scadenza'],
'properties' => [
'fornitore' => ['type' => 'string', 'maxLength' => 200],
'imponibile' => ['type' => 'number', 'minimum' => 0],
'iva' => ['type' => 'number', 'minimum' => 0, 'maximum' => 100],
'scadenza' => ['type' => 'string', 'format' => 'date'],
],
],
]],
'tool_choice' => ['type' => 'tool', 'name' => 'extract_invoice'],
'messages' => [...],
]);La validazione lato provider elimina una larga parte dei failure mode strutturali. Il modello è costretto a produrre JSON che matcha lo schema; i casi di output malformato diventano rari (nella mia esperienza meno dell'1% contro il 5% di prompt-driven). Il costo è che sei legato alle capacità del provider - se un giorno cambi da Anthropic a un modello self-hosted senza tool use nativo, devi riscrivere tutta la logica di validazione. Il mio baseline attuale è usare tool use dove disponibile e double-validate comunque lato PHP, perché la defense in depth costa pochi millisecondi e ti protegge da edge case e da regressioni del provider.
Cosa ho scelto come default nei progetti di consulenza
Nella mia pipeline personale il default che propongo ai clienti è una combinazione a due livelli: tool use lato API + DTO con attributi Symfony Validator lato applicazione. La ragione è pragmatica. Il tool use API-side elimina il 95% dei problemi strutturali a costo zero di codice lato PHP; il DTO con attributi lato applicazione copre il 5% residuo (casi di edge business rule che il JSON Schema del tool non può esprimere: per esempio "se l'iva è 22 allora la scadenza deve essere entro 30 giorni"), mantiene il codice self-documenting e type-safe, riusa l'ecosistema di Exception e Listener che già esiste in una tipica app Symfony o Laravel.
Il fallback che applico in produzione ha tre livelli. Primo livello: se la validazione fallisce al primo tentativo, la pipeline tenta un retry con un prompt che include l'errore di validazione come context ("il tuo output precedente ha fallito la validazione perché: {errore}. Produci nuovamente seguendo lo schema strict"). Questo recupera circa il 70% dei casi di errore nella mia esperienza. Secondo livello: se il retry fallisce, il record va in una dead letter queue che un operatore umano revisiona nella dashboard interna - tipicamente 2-5 record al giorno per una pipeline da 10.000 documenti giornalieri. Terzo livello: se il volume di errori eccede una soglia (tipicamente 2% in una finestra di 10 minuti), la pipeline attiva circuit breaker e blocca l'accettazione di nuovo lavoro finché un umano non verifica. Il circuit breaker è la protezione contro cambio di versione modello LLM che produce drift comportamentale.
Quali sono le metriche che tengo per sapere se il sistema funziona
La metrica primaria è il tasso di validation failure al primo tentativo. Sulla mia pipeline di estrazione fatture è stabile attorno allo 0,8% con tool use Claude Sonnet 4.6. Il tasso di retry recovery è attorno al 72%, quindi il tasso netto di dead letter è sotto il 0,3% - gestibile da un operatore a tempo parziale. Il tasso di falsi positivi (record validati ma business-wrong) è misurato su audit mensile manuale su un campione del 5% del batch elaborato ed è attualmente sotto lo 0,5%.
Queste metriche sono la base per conversazioni serie con clienti che valutano LLM in produzione. Il report Deloitte "State of AI in the Enterprise 2026" riporta che il 79% delle aziende che pianificano deploy di agenti autonomi non ha ancora una governance matura, e Gartner stima che oltre il 40% dei progetti agentic verrà cancellato entro fine 2027 per inadequate risk controls. Il risk control che separa un progetto che sopravvive da uno che viene cancellato è esattamente la disciplina sull'output validation e sui fail-safe che ho descritto. Non è sofisticato concettualmente, ma è lavoro ingegneristico rigoroso che va fatto, e che il marketing del settore tende a sottovalutare nelle demo.
Se stai valutando di portare un processo aziendale su pipeline LLM e vuoi una qualificazione rapida del livello di rigidità che serve al tuo caso specifico, il modulo di preventivo gratuito ti risponde in sette domande - circa due minuti - e ti dice se il tuo scenario rientra nel mio ambito o ti indirizzo verso figure più adatte. Per il lavoro preliminare sull'architettura che ospiterà la pipeline, dove la robustezza delle code e la gestione degli errori sono precondizione critica, trovi un inquadramento operativo nel mio articolo su monitoring proattivo Laravel per prevenire downtime e su Laravel Horizon emergenza Redis VPS. La structured output validation è dove si gioca la differenza tra una demo convincente e una pipeline che dorme tranquilla la notte: è poco visibile dall'esterno, ma è la disciplina che permette all'LLM di stare in produzione senza diventare un incident pending.