API versioning in Laravel: strategie pratiche per API pubbliche che evolvono senza rotture
A luglio 2025 ho ereditato la responsabilità tecnica di un'API Laravel 9 di un marketplace B2B italiano con 15 anni di storia di business, circa 18.000 clienti registrati e un'integrazione attiva con 40 sistemi esterni: gestionali dei fornitori, piattaforme di procurement di grandi buyer, ERP di rivenditori, piattaforme di logistica. L'API era stata costruita progressivamente negli ultimi otto anni senza alcun concetto di versioning: un singolo endpoint /api/catalogo/prodotti che restituiva una struttura JSON evolutasi organicamente, con campi aggiunti mese dopo mese, rimossi raramente, rinominati in modo compatibile quando possibile e in modo distruttivo quando nessuno se ne era accorto. Il risultato era che circa un terzo delle 40 integrazioni attive restituiva errori intermittenti ogni volta che il team dell'azienda committava una modifica all'API - in alcuni casi gli integratori avevano scritto codice così rigido che un campo in più nella risposta JSON (previsto dal contratto REST come benigno) rompeva il loro parser. La discussione più lunga che ho visto accadere, prima del mio subentro, era durata tre mesi: aggiungere il campo iva_margine al prodotto per supportare il regime fiscale del margine, cosa che sarebbe stata banale in un'API versionata, ma che era esploso in una saga di telefonate, email e workaround perché nessuna delle 40 integrazioni era stata avvisata preventivamente e ognuna si era rotta in modo leggermente diverso.
In sei settimane ho introdotto versioning disciplinato sull'API con un approccio non distruttivo: la v1 attuale è stata congelata nel comportamento esatto al giorno zero, è stata creata una v2 che porta tutte le modifiche pianificate nei successivi sei mesi, e ho scritto un contratto di deprecation che l'azienda ha comunicato formalmente ai 40 integratori con una finestra di migrazione di 12 mesi. Nel frattempo, entrambe le versioni convivono, ognuna con la sua documentazione OpenAPI separata, ognuna con il suo canale di comunicazione per breaking change. A nove mesi dal rollout del versioning, 32 integratori su 40 hanno migrato alla v2, 5 sono in migrazione attiva, e 3 sono fermi su v1 ma sotto contratto di supporto esteso fino al rilascio di v3. Il tempo per introdurre nuove feature sull'API è crollato da "tre mesi di negoziazione" a "due settimane di sviluppo + due settimane di handoff". Questo articolo descrive esattamente come ho strutturato il versioning, le scelte pattern che ho fatto, e soprattutto come ho gestito la fase più delicata - la comunicazione con gli integratori esistenti.
URL versioning o header versioning: la scelta che dipende dai tuoi clienti reali
La prima decisione architetturale è fra URL versioning (/api/v1/prodotti vs /api/v2/prodotti) e header versioning (Accept: application/vnd.company.v2+json sullo stesso URL). Entrambi sono legittimi e hanno sostenitori autorevoli. La mia scelta pragmatica si basa quasi sempre su un singolo criterio: cosa è più facile da implementare e monitorare per i tuoi clienti reali.
URL versioning vince quasi sempre nei contesti B2B italiani con integratori di diversa maturità. È immediatamente leggibile nei log, nei tool di debug come Postman o Insomnia, nei sistemi di monitoring. È ovvio a chiunque legga la URL che /api/v2/prodotti è una versione diversa da /api/v1/prodotti. Gli integratori con team tecnici meno maturi capiscono intuitivamente come consumare la v2 senza dover capire content negotiation HTTP. È trivial da routare nel load balancer o API gateway - versioni diverse possono andare a backend diversi se servisse.
Header versioning vince nei contesti con integratori di alta maturità tecnica dove il versioning semantico via Accept è già una convenzione. È preferito in team che seguono rigorosamente REST/HATEOAS dove l'URL rappresenta la risorsa e non la rappresentazione. Ha il vantaggio di non "inquinare" l'URL con dettagli implementativi, e teoricamente permette evoluzioni più granulari (v2.1, v2.2, v2.3) senza cambio di URL. Il costo è che molti client scritti con librerie HTTP semplici (curl in bash, Postman quando non configurato bene) non gestiscono bene l'header custom e finiscono per fallire in modo non ovvio.
Sul marketplace del 2025 ho scelto URL versioning perché gli integratori erano molto eterogenei - alcuni erano SaaS enterprise con team tecnici qualificati, altri erano script PHP 7.4 scritti dal nipote del titolare del fornitore che erano in produzione da 8 anni senza essere mai stati toccati. Per quest'ultima fascia, URL versioning era l'unica scelta che non avrebbe richiesto spiegazioni lunghe. Il pattern che ho configurato in Laravel è questo, con gruppi di rotte dedicati in routes/api.php:
// routes/api.php
use Illuminate\Support\Facades\Route;
// Versione 1 congelata: comportamento preservato esattamente
Route::prefix('v1')->name('v1.')
->middleware(['auth:sanctum', 'throttle:api-v1'])
->group(function () {
Route::get('/catalogo/prodotti', [\App\Http\Controllers\Api\V1\ProdottoController::class, 'index']);
Route::get('/catalogo/prodotti/{id}', [\App\Http\Controllers\Api\V1\ProdottoController::class, 'show']);
// ... altre 40 rotte v1
});
// Versione 2 attiva: evolve con breaking change consentiti
Route::prefix('v2')->name('v2.')
->middleware(['auth:sanctum', 'throttle:api-v2'])
->group(function () {
Route::get('/catalogo/prodotti', [\App\Http\Controllers\Api\V2\ProdottoController::class, 'index']);
Route::get('/catalogo/prodotti/{id}', [\App\Http\Controllers\Api\V2\ProdottoController::class, 'show']);
// ... rotte v2 con nuovi campi e comportamento
});
// Versione canonica senza prefisso: alias di v1 per backward compatibility totale
Route::prefix('/')->name('canonical.')
->middleware(['auth:sanctum', 'throttle:api-canonical'])
->group(function () {
Route::get('/catalogo/prodotti', [\App\Http\Controllers\Api\V1\ProdottoController::class, 'index']);
Route::get('/catalogo/prodotti/{id}', [\App\Http\Controllers\Api\V1\ProdottoController::class, 'show']);
});Il pattern "canonical senza prefisso" è quello che permette di introdurre il versioning retroattivamente senza rompere gli integratori esistenti: finché non migrano esplicitamente a /api/v1/ o /api/v2/, continuano a chiamare /api/prodotti senza prefisso e ricevono il comportamento v1 congelato. Questa è la chiave dell'adozione non traumatica del versioning.
Controller e Resource: il pattern che evita la duplicazione ma supporta la divergenza
Il primo istinto dei developer quando devono gestire due versioni di un controller è duplicare l'intero controller. Questa strategia funziona per le prime settimane e poi collassa: quando una logica di business cambia (nuova validazione, nuova regola di autorizzazione, bug fix), bisogna ricordarsi di propagare la modifica su entrambe le versioni, e immancabilmente qualcuno lo dimentica. Il pattern più robusto è separare la logica di business dalla rappresentazione, mantenendo service layer condivisi e variando solo il controller + la Eloquent API Resource che gestisce la serializzazione.
L'architettura è questa: un singolo ProdottoService che contiene la logica di business (fetch, filter, pagination, autorizzazione), due controller V1\ProdottoController e V2\ProdottoController che sono wrapper sottili attorno al service, due Eloquent Resource V1\ProdottoResource e V2\ProdottoResource che gestiscono la serializzazione in modo diverso. Un esempio della Resource v1 congelata:
<?php
// app/Http/Resources/V1/ProdottoResource.php
namespace App\Http\Resources\V1;
use Illuminate\Http\Resources\Json\JsonResource;
class ProdottoResource extends JsonResource
{
// Comportamento V1 CONGELATO al giorno zero del versioning
// Nessun campo nuovo viene aggiunto qui, nessun campo rinominato.
public function toArray($request): array
{
return [
'id' => $this->id,
'codice' => $this->codice_articolo,
'descrizione' => $this->descrizione,
'prezzo' => (float) $this->prezzo_listino,
'disponibilita' => $this->giacenza > 0,
'categoria' => $this->categoria_nome,
// NB: il V1 non espone campo iva_margine deliberatamente
];
}
}E la Resource v2 aggiornata:
<?php
// app/Http/Resources/V2/ProdottoResource.php
namespace App\Http\Resources\V2;
use Illuminate\Http\Resources\Json\JsonResource;
class ProdottoResource extends JsonResource
{
// V2 evolve con breaking change consentiti, nuovi campi, nuovi tipi
public function toArray($request): array
{
return [
'id' => $this->id,
'codice' => $this->codice_articolo,
'descrizione' => $this->descrizione,
// V2: il prezzo diventa un oggetto con valuta esplicita
'prezzo' => [
'valore' => (float) $this->prezzo_listino,
'valuta' => $this->valuta ?? 'EUR',
'iva_margine' => (bool) $this->regime_iva_margine,
],
// V2: disponibilita' diventa numero intero invece di bool
'quantita_disponibile' => (int) $this->giacenza,
'categoria' => [
'id' => $this->categoria_id,
'nome' => $this->categoria_nome,
],
];
}
}La regola d'oro è: v1 non si tocca mai, qualunque cosa succeda nel codice. Se si scopre un bug in v1, si valuta se fixarlo è realmente un bug (la documentazione API dice un comportamento, il codice ne faceva un altro) o una feature che gli integratori si sono abituati a consumare e che quindi fixare sarebbe di fatto un breaking change. In quest'ultimo caso, la "fix" viene applicata solo a v2. Questa disciplina è difficile da sostenere in team giovani ma è non negoziabile se si vuole che v1 resti affidabile.
Stai cercando un Consulente Informatico esperto per introdurre versioning disciplinato su un'API Laravel pubblica con integratori esterni, senza traumatizzare la comunità di consumer e con un piano di deprecation realistico e sostenibile? Nel mio profilo professionale trovi l'esperienza concreta su API design, governance di API pubbliche, strategia di migrazione di integratori terzi e pipeline Laravel in produzione.
Il contratto di deprecation: il documento che cambia la conversazione con gli integratori
Il versioning tecnico è metà del lavoro. L'altra metà è il contratto di deprecation, un documento formale che definisce le regole del gioco fra l'azienda che fornisce l'API e gli integratori che la consumano. Senza questo documento, ogni breaking change diventa una telefonata da gestire individualmente con ognuno dei 40 integratori; con il documento, le regole sono scritte una volta e valgono per tutti.
Il contratto di deprecation che ho scritto per il marketplace è lungo tre pagine e copre sei aspetti. Primo: la versioning policy, che dichiara che l'API segue versioning semantico al livello major (v1, v2, v3), che v1 viene preservata indefinitamente al comportamento originario, e che le nuove versioni vengono rilasciate non più frequentemente di una volta ogni 12 mesi. Secondo: la deprecation timeline, che stabilisce che quando una versione major viene deprecata, gli integratori hanno una finestra di 12 mesi per migrare, con comunicazione formale all'inizio e reminder a 6 mesi, 3 mesi, 1 mese dalla data di dismissione. Terzo: la breaking change policy, che elenca cosa l'azienda considera breaking change (rimozione di campi, cambio di tipo di un campo esistente, cambio di comportamento su valori edge-case, cambio di codici errore, rimozione di endpoint) e cosa invece non considera breaking (aggiunta di campi nuovi con valore opzionale, aggiunta di nuovi endpoint, aggiunta di nuovi codici errore per casi che prima generavano errore generico). Quarto: la changelog policy, che impegna l'azienda a mantenere un documento pubblico su /api/changelog con tutti i cambiamenti di ogni versione, datati e con esempi. Quinto: la communication channel policy, che designa un canale ufficiale (email dedicata [email protected]) per tutte le comunicazioni relative al versioning, e richiede agli integratori di comunicare i loro referenti tecnici per ricevere notifiche. Sesto: le metriche di supporto, che definiscono SLA di risposta alle richieste su bug documentati (24 ore lavorative per severity high), finestre di manutenzione programmata con preavviso di almeno 72 ore, e disponibilità dichiarata.
Il documento è stato firmato dall'azienda del cliente e controfirmato da ognuno dei 40 integratori come allegato tecnico al contratto di servizio esistente. La prima controfirma è stata la più lenta - un integratore particolarmente conservativo ha fatto passare il documento al suo legal team che ha richiesto due round di modifiche minori - ma le successive 39 sono state rapide perché il modello era già stato validato con la prima. La firma ha cambiato la natura della conversazione: da "ci possiamo aspettare che l'API cambi così?" a "la versione 2 è disponibile da luglio, avete 12 mesi per migrare, le istruzioni sono a questo link".
Monitoring delle versioni: sapere chi sta usando cosa è metà del lavoro
Il rollout del versioning non è completo senza il monitoring. Senza dati su quali client usano v1 e quali v2, è impossibile gestire una migrazione ordinata: potrebbe capitare di dismettere v1 scoprendo tardivamente che un integratore critico è ancora lì. Il pattern che uso è di loggare ogni chiamata API con quattro attributi: versione usata, identificativo del client (dall'API key autenticata), endpoint chiamato, timestamp. Questi log vanno in un ClickHouse (o un database analitico equivalente) dedicato, non nei log applicativi generici, per permettere query veloci su intervalli temporali lunghi.
Il middleware Laravel che intercetta le richieste è sottile:
<?php
// app/Http/Middleware/LogApiUsage.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class LogApiUsage
{
public function handle(Request $request, Closure $next)
{
$response = $next($request);
// log asincrono via queue per non impattare latenza
\App\Jobs\LogApiCall::dispatch([
'client_id' => $request->user()->id ?? 'anonymous',
'client_name' => $request->user()->name ?? '',
'version' => $this->detectVersion($request),
'endpoint' => $request->path(),
'method' => $request->method(),
'status' => $response->getStatusCode(),
'response_time_ms' => (int) ((microtime(true) - LARAVEL_START) * 1000),
'timestamp' => now()->toIso8601String(),
])->onQueue('metrics');
return $response;
}
private function detectVersion(Request $request): string
{
$path = $request->path();
if (str_starts_with($path, 'api/v2/')) return 'v2';
if (str_starts_with($path, 'api/v1/')) return 'v1';
return 'canonical';
}
}Con questi dati, posso produrre dashboard che mostrano: quanti client usano ciascuna versione settimana dopo settimana, quali client sono ancora sulla v1 a 6 mesi dalla disponibilità di v2, quali endpoint sono più usati e quindi più critici da preservare, quali client hanno traffico declining su v1 (segno che stanno migrando) e quali mantengono traffico costante (segno che non hanno iniziato). Il team commerciale dell'azienda usa questi dati per identificare proattivamente i clienti che hanno bisogno di supporto nella migrazione, invece di aspettare che scoprano di avere un problema alla deadline di dismissione. Questo è coerente con il pattern operativo di osservabilità minima per applicazioni PHP legacy che descrivo in un articolo dedicato - la disciplina della telemetria è quella che trasforma un'API versionata da "sistema con rischio di rotture silenziose" a "sistema osservabile con governance prevedibile".
La trappola più frequente: "v1.5 con solo qualche fix"
Il pattern più pericoloso che vedo nei team che hanno introdotto il versioning è il cedimento alla tentazione di "fixare qualcosa in v1" nonostante l'impegno di tenerla congelata. Il pattern si manifesta così: il team rileva un bug in v1, decide che "è solo un piccolo fix", modifica il codice di v1 in produzione, scopre che uno o due integratori avevano quietamente dipeso dal comportamento buggy, e rompe quelle due integrazioni. A quel punto deve fare rollback del fix, e nel frattempo ha bruciato la credibilità della propria versioning policy.
La disciplina che impongo è che ogni cambiamento al codice di v1 richiede una motivazione scritta che descriva perché quel cambiamento non è un breaking change per nessun integratore plausibile. La motivazione va discussa in review con un secondo ingegnere e, nei casi dubbi, annunciata preventivamente agli integratori (con preavviso di 30 giorni) come "security fix" o "performance optimization" che non altera il contratto funzionale. Se non si può dimostrare che il cambiamento è innocuo per tutti, va portato in v2 e lasciato fuori da v1, anche se v1 rimane con il bug. Questa è una scelta scomoda ma è l'unica che preserva la stabilità della versione fornita ai client che non hanno ancora migrato.
Un pattern correlato è "aggiungiamo solo un campo a v1, non è breaking". È vero che nel modello REST puro l'aggiunta di un campo non è breaking (i client dovrebbero ignorare i campi che non conoscono), ma come ho scoperto sul marketplace del 2025, non tutti i client sono scritti bene. Alcuni deserializzano in strutture strict dove un campo inatteso causa eccezione. La regola pragmatica è: non aggiungere mai campi a una versione congelata. Se hai bisogno di un campo nuovo, lo aggiungi solo alla versione in evoluzione (v2) e comunichi gli integratori della necessità di migrare per averlo.
Il piano di decommissioning di una versione vecchia
A 12-18 mesi dalla deadline di dismissione di v1, il piano diventa operativo. La sequenza che applico è in cinque fasi. Fase 1 (T-12 mesi): comunicazione formale via email certificata a tutti gli integratori con dichiarazione della dismissione, link alla migration guide v1→v2, offerta di sessioni di supporto tecnico gratuite per i primi 30 giorni. Fase 2 (T-6 mesi): report mensile a ogni integratore sul loro uso corrente di v1 con confronto al traffico del mese precedente. Fase 3 (T-3 mesi): per gli integratori ancora al 100% su v1, contatto diretto del sales manager con il loro referente tecnico per capire il blocker. Fase 4 (T-1 mese): attivazione di rate limiting progressivo su v1 per gli integratori che non hanno iniziato la migrazione - riduzione del 10% a settimana del loro budget di richieste, come segnale forte ma non distruttivo. Fase 5 (T-0): v1 restituisce 410 Gone con un body JSON che rimanda alla documentazione v2. Non 404 perché vorrebbe dire "endpoint non trovato", non 301 perché non esiste un equivalente diretto - 410 è il codice semantico corretto per "questa risorsa esisteva ma è stata rimossa intenzionalmente".
Sul marketplace, al mese T-0 pianificato per la dismissione di v1, tre integratori erano ancora attivi su v1. L'azienda ha valutato caso per caso: uno è stato accompagnato in un progetto di migrazione in un mese (con fee aggiuntiva), uno ha scelto di uscire dal contratto di integrazione perché non era più strategico per lui, il terzo ha negoziato un'estensione di 6 mesi con fee di manutenzione dedicata. La dismissione effettiva è avvenuta con un ulteriore slittamento controllato di 4 mesi rispetto al T-0 iniziale, ma l'intero processo è stato ordinato, comunicato formalmente, e non ha generato dispute legali o perdita di clienti critici. Il pattern che ho descritto in dettaglio nel mio approfondimento sulle sei abitudini di un senior developer e sulla definition of done estesa che include il monitoring del cambio in produzione applica qui nella sua forma più pura: il decommissioning di una versione non è "mergiato e chiuso", è un processo che dura mesi e si misura con dati e comunicazione attiva.
Il beneficio secondario che nessuno previde: la roadmap diventa più facile
Il beneficio che gli sponsor del progetto di versioning sottovalutano all'inizio ma che emerge con chiarezza dopo sei mesi è l'impatto positivo sulla roadmap di prodotto. Prima del versioning, ogni breaking change era una crisi diplomatica da evitare, e la roadmap dell'API si appiattiva verso il "minimo comune denominatore che nessuno rompe". Dopo il versioning, breaking change ben progettati diventano possibili: si annunciano come parte della prossima major version, gli integratori sanno dove aspettarseli, la roadmap può permettersi di modernizzare aspetti strutturali (cambio di formato date da stringa libera a ISO8601 strict, sostituzione di enum stringa con oggetti strutturati, introduzione di relazioni nidificate in risposta che prima richiedevano chiamate separate).
Sul marketplace, in 12 mesi dalla v2 abbiamo introdotto nove cambiamenti architetturali che con l'API monolitica sarebbero stati inimmaginabili: paginazione cursor-based invece di offset, supporto per campi calcolati server-side, unificazione del formato degli errori in stile RFC 7807 (descritto nel mio articolo sull'error handling HTTP moderno in Laravel 12 con RFC 7807 Problem Details per API PMI), introduzione di idempotency key per richieste non idempotenti per design, e altro. Ogni cambio è stato pianificato, comunicato, rilasciato, e adottato senza drammi. L'API è diventata un prodotto che evolve a ritmo prevedibile, non un asset congelato da proteggere dal cambiamento.
Se gestisci un'API Laravel pubblica con integratori terzi e senti che ogni modifica si trasforma in una negoziazione mese dopo mese, oppure stai per rilasciare un'API nuova e vuoi partire con le fondamenta giuste per evitare il debito architetturale che vedo in progetti più vecchi, contattami per una consulenza architetturale: in una giornata analizzo la struttura attuale o pianificata della tua API, valuto la strategia di versioning più adatta ai tuoi client reali (URL vs header, major-only vs semver completo, canonical fallback), ti consegno una bozza di contratto di deprecation calibrato sul tuo contesto, e imposto il pattern controller/service/resource separati che permette di mantenere versioni multiple senza duplicazione pervasiva di codice.