API Platform con Symfony: generare API REST e GraphQL da modelli Doctrine
A maggio 2025 un'azienda del settore servizi digitali per il fintech italiano mi ha chiesto di costruire l'API di un prodotto SaaS nuovo per la gestione di contratti di locazione operativa di attrezzature industriali, destinato a un target di 200-400 PMI clienti. Il core domain era complesso ma ben strutturato: 28 entità Doctrine ben modellate, con relazioni articolate (un contratto ha molteplici rate, ogni rata ha allegati multipli, ogni contratto referenzia il locatore, il locatario, l'attrezzatura, le garanzie, i documenti notarili). Il team di sviluppo interno era di tre persone con esperienza Symfony solida ma non specialistica, e il budget per l'API era calibrato su 6 settimane di lavoro full-time di due developer, con finestra di consegna fissa dettata dal rilascio coordinato con il frontend React sviluppato esternamente. Costruire un'API REST completa con filtri, paginazione, validazione, autorizzazione granulare, documentazione OpenAPI, versioning e testing su 28 entità in sei settimane avrebbe richiesto un team con competenze molto superiori e comunque sarebbe stato rischioso. La scelta architetturale è stata di usare API Platform come framework sopra Symfony, documentato ufficialmente su api-platform.com con guida canonica al setup e all'estensione, che genera automaticamente CRUD REST, documentazione OpenAPI e supporto GraphQL opzionale a partire da annotazioni PHP sulle entità Doctrine.
Il risultato al termine delle sei settimane è stato che l'80% degli endpoint base (create, read, update, delete, list con filtri e paginazione) era stato generato automaticamente in tre giorni di setup iniziale, e le successive cinque settimane sono state dedicate ai casi d'uso dove la generazione automatica non bastava: logiche di business custom sui contratti, integrazioni con firma digitale, webhook verso sistemi esterni di credito, permessi granulari per ruoli utente enterprise. La frase che ripeto a ogni cliente su API Platform è: è il framework che ti fa arrivare molto velocemente al 70-80% del lavoro, a patto che tu sappia quando smettere di usarlo e quando scrivere codice custom. In questo articolo descrivo esattamente dove si colloca quel confine, con esempi concreti presi dal progetto fintech di cui sopra e da altri tre progetti simili portati a termine nel 2024-2025.
Quando API Platform conviene davvero: i tre criteri di applicabilità
API Platform non è un silver bullet, è uno strumento altamente opinionato che eccelle in un preciso sottoinsieme di casi d'uso. I tre criteri che uso per decidere se proporlo come baseline architetturale sono questi. Primo: il dominio è modellato bene come aggregato di entità relazionali, con una chiara corrispondenza fra entità Doctrine e resource API. Se il tuo modello è event-driven o command-driven (tipico dei sistemi finanziari ad alta frequenza), API Platform è un match forzato e il codice generato non riflette la vera struttura del dominio. Secondo: i pattern CRUD sono il 60-80% del lavoro richiesto, con customizzazioni possibili ma non pervasive. Se invece ogni endpoint è una logica di business custom (calcoli complessi, aggregazioni su tabelle multiple, transazioni distribuite), la generazione automatica ti aiuta poco e passi più tempo a bypass del framework che a sfruttarlo. Terzo: il team ha esperienza Symfony e Doctrine ed è disposto a investire tempo nell'imparare le convenzioni API Platform - una settimana di formazione iniziale che si ripaga nelle successive 4-5 settimane di sviluppo.
Il progetto fintech del 2025 rientrava in tutti e tre i criteri: dominio CRUD classico ma ricco di attributi e relazioni, 70-80% del lavoro coperto dai pattern standard, team Symfony pronto ad assimilare le convenzioni del framework. Il secondo progetto dove l'ho applicato nello stesso anno era un SaaS di project management per studi professionali, anch'esso un match perfetto. Un terzo progetto dove ho escluso API Platform era un sistema di gestione eventi live-streaming dove ogni "evento" era in realtà una macchina a stati complessa con transizioni strict e logica pesante - lì un'architettura a controller custom con CQRS è stato il match corretto, e API Platform sarebbe stato un ostacolo.
Il setup base: da entità Doctrine a API REST in 30 minuti
L'esperienza di sviluppo con API Platform nei primi giorni è quasi sorprendente per chi non l'ha mai usato. Si installa il bundle con Composer, si aggiungono attributi PHP 8 alle entità Doctrine esistenti, e nell'arco di mezz'ora si ha un'API REST con tutti gli endpoint CRUD per ogni entità, una documentazione Swagger UI automaticamente generata, e - se si abilita il supporto - un endpoint GraphQL completo sullo stesso schema. Ecco come appare un'entità del progetto fintech:
<?php
// src/Entity/Contratto.php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Delete;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Serializer\Annotation\Groups;
#[ORM\Entity]
#[ApiResource(
operations: [
new Get(normalizationContext: ['groups' => ['contratto:read']]),
new GetCollection(normalizationContext: ['groups' => ['contratto:list']]),
new Post(
denormalizationContext: ['groups' => ['contratto:write']],
security: "is_granted('ROLE_OPERATORE')"
),
new Patch(
denormalizationContext: ['groups' => ['contratto:update']],
security: "is_granted('EDIT', object)"
),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
paginationItemsPerPage: 30,
paginationMaximumItemsPerPage: 100
)]
class Contratto
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['contratto:read', 'contratto:list'])]
private ?int $id = null;
#[ORM\Column(length: 50)]
#[Assert\NotBlank]
#[Assert\Length(max: 50)]
#[Groups(['contratto:read', 'contratto:list', 'contratto:write'])]
private string $numeroContratto;
#[ORM\Column]
#[Assert\NotNull]
#[Assert\GreaterThan(0)]
#[Groups(['contratto:read', 'contratto:write', 'contratto:update'])]
private float $importoTotale;
// ... altre 20 proprieta con relazioni ManyToOne, OneToMany, etc.
}Con questa singola annotazione, API Platform genera automaticamente: GET /api/contratti con paginazione e filtri, GET /api/contratti/{id} per il dettaglio, POST /api/contratti con validazione automatica dei campi, PATCH /api/contratti/{id} per update parziali, DELETE /api/contratti/{id}. Ogni endpoint include headers di paginazione (X-Total-Count, Link con next/prev/last), supporto per content negotiation (JSON-LD, JSON:API, HAL, GraphQL), documentazione OpenAPI 3 scaricabile a /api/docs.json, interfaccia Swagger UI a /api/docs, e supporto per filtri, sorting e embed di relazioni.
Il beneficio è sproporzionato rispetto al costo di scrivere quelle annotazioni. Un developer esperto Symfony impiegherebbe tipicamente 2-3 giorni per costruire lo stesso set di endpoint a mano per una singola entità, con tutta la logica di validazione, serializzazione, autorizzazione, paginazione, filtri. Su 28 entità del progetto fintech, questo significa l'equivalente di 60-80 giorni-persona di lavoro risparmiato - che è esattamente perché il budget di 6 settimane totali ha potuto coprire un'API così estesa.
I gruppi di serializzazione: il meccanismo che separa API Platform da tutto il resto
La feature di API Platform che fa la vera differenza in progetti reali sono i serialization groups. Ogni operazione (GET collection, GET item, POST, PATCH) può avere il suo normalization context e denormalization context - un set di gruppi che determina quali campi vengono esposti o accettati. Questo permette di avere, sulla stessa entità, viste API diverse senza duplicare il modello dati.
Nell'esempio sopra, il gruppo contratto:list esposto in GET collection contiene solo id e numero contratto (per efficienza di lista), il gruppo contratto:read esposto in GET dettaglio contiene anche importoTotale e relazioni pesanti, il gruppo contratto:write accetta solo i campi che l'utente può impostare in creazione (non l'id autogenerato, non i campi calcolati), il gruppo contratto:update accetta un subset ulteriore per le modifiche successive.
Il pattern che uso come baseline nei progetti è questo: quattro gruppi per entità (entita:list, entita:read, entita:write, entita:update), con list più leggero di read per performance, e write/update calibrati sui permessi utente. Su 28 entità del progetto fintech, questa disciplina ha permesso di evitare errori di esposizione di dati sensibili - un problema che vedo spesso in progetti API scritti a mano dove la stessa risorsa a volte ritorna il campo passwordHash e a volte no, a seconda del developer che ha scritto quell'endpoint.
Il Symfony Serializer Component che sta sotto API Platform è documentato in dettaglio nella documentazione ufficiale di Symfony sul componente Serializer, ed è il motore che rende possibile questa granularità. Un effetto collaterale positivo è che i gruppi sono riutilizzabili anche per export CSV, import di dati da fonti esterne, e serializzazione per code asincrone - tutto attraverso lo stesso meccanismo.
Stai cercando un Consulente Informatico esperto per architettare un'API complessa su Symfony usando API Platform in modo disciplinato, senza rimanere intrappolato nella generazione automatica quando il dominio richiede logica custom? Nel mio profilo professionale trovi l'esperienza concreta su API design, Symfony enterprise, Doctrine ORM ottimizzato e architetture a microservizi per progetti SaaS italiani.
Dove finisce la magia: tre scenari dove API Platform va esteso
Il punto in cui molti team si perdono è esattamente quello dove API Platform smette di generare automaticamente e inizia a richiedere codice custom. I tre scenari principali dove questo succede, con i pattern di soluzione che applico in ognuno, sono i seguenti.
Primo scenario: logiche di business che non sono CRUD. Nel progetto fintech, un'operazione comune era "calcola la rata mensile di un contratto dato importo, durata e tasso di interesse". Questa non è una operazione di lettura/scrittura di un'entità - è un calcolo. API Platform supporta i custom operations come azioni sulle entità, ma per calcoli puri senza effetti collaterali sul database preferisco esporre custom controllers dedicati fuori dalle operazioni standard di risorsa. Il controller riceve i parametri, li valida con Symfony Validator, invoca un Service di dominio che fa il calcolo, e restituisce il risultato come JSON. Una decina di endpoint del fintech erano di questo tipo - non generati automaticamente, ma coerenti con il resto dell'API in stile di risposta e di documentazione OpenAPI (annotati manualmente con attributi #[OA\...]).
Secondo scenario: logiche di autorizzazione granulari. La security expression is_granted('EDIT', object) nell'esempio sopra è potente ma risolve solo casi semplici. Nel fintech, la regola era: "un operatore può modificare un contratto solo se è stato il creatore o se è di un cliente a lui assegnato". Questa regola richiede un Voter Symfony custom che riceva subject (il Contratto), attribute (EDIT), e token (l'utente autenticato), e decida in base alla query sul database delle assegnazioni cliente-operatore. API Platform supporta completamente i Voter Symfony standard attraverso security, quindi basta scrivere il Voter e referenziarlo. Il pattern è identico a quello che userei in un'API Symfony scritta a mano, ma l'integrazione è automatica.
Terzo scenario: query complesse con filtri custom. I filtri standard di API Platform (search, order, date filter, numeric range filter) coprono il 70% dei casi. Per il resto, si scrivono filtri custom estendendo AbstractFilter. Nel fintech un filtro custom frequente era "contratti con rate in scadenza nei prossimi N giorni", che richiedeva una subquery su una tabella correlata. L'estensione è un'ora di lavoro e produce un filtro testabile e riutilizzabile che appare anche nella documentazione OpenAPI con i parametri corretti.
GraphQL: quando attivarlo, quando no
API Platform genera automaticamente un endpoint GraphQL parallelo al REST a partire dalle stesse annotazioni, semplicemente aggiungendo graphQlOperations alle operazioni dichiarate nella risorsa. Il supporto è completo: query, mutation, subscription via Mercure, filtri, paginazione cursor-based, nested queries con N+1 resolver automatico. Sul progetto fintech abbiamo attivato GraphQL a metà del percorso di sviluppo, quando il team frontend React ha chiesto di ottimizzare specifiche pagine che facevano 6-8 chiamate REST per comporre una singola view. Il frontend ha riscritto quelle pagine in un'unica query GraphQL che richiedeva esattamente i campi necessari da entità diverse, con un calo del traffico API del 60% e un miglioramento della latency percepita del 40%.
La decisione di attivare GraphQL non è tuttavia universale, e sconsiglio spesso di abilitarlo by default. Primo: aggiunge un endpoint in più da proteggere, monitorare e documentare, e introduce complessità per il team operations. Secondo: GraphQL per sua natura consente query che il team backend non ha previsto, con possibili impatti di performance imprevisti (N+1 pesanti se il frontend richiede nested deep, query molto complesse che saturano il database). Terzo: se il frontend è un'applicazione singola e controllata, REST è spesso sufficiente e meglio comprensibile. Sul progetto fintech GraphQL ha avuto senso perché il frontend aveva necessità reali di composizione; su altri progetti con API B2B consumate da client esterni eterogenei, l'abbiamo escluso perché la prevedibilità di REST era un valore e la cardinalità dei client rendeva difficile coordinare l'uso efficiente di GraphQL. Ho approfondito il tema dell'API design evoluto in un altro ambito nel mio articolo su OpenAPI e Swagger per la documentazione di API Laravel generata dal codice, dove discuto il pattern complementare per progetti Laravel.
Le insidie di performance: N+1 e serialization cost
Il lato scuro di API Platform è che genera molto codice, e quel codice fa molte query Doctrine. Senza attenzione, è facile finire in scenari N+1 brutali: un GET collection su 100 contratti che serializza ogni contratto con le sue rate (OneToMany) può generare 101 query al database se il fetch mode di default è LAZY. La soluzione è doppia. Prima: configurare fetch join esplicito sulle relazioni caricate normalmente - si può fare via event listener API Platform che modifica il Doctrine QueryBuilder prima dell'esecuzione, aggiungendo leftJoin e addSelect per le relazioni richieste dal gruppo di serializzazione attivo. Seconda: utilizzare il plugin DoctrineExtensions che integrazioni come Doctrine ORM ben configurata con lazy loading e fetch mode corretti, descritta nella documentazione ufficiale di Doctrine rendono nativamente supportate.
Sul progetto fintech il primo deploy in staging ha rivelato che l'endpoint /api/contratti su 200 contratti eseguiva 401 query (1 per la collezione + 200 per le rate + 200 per i locatari). Dopo l'aggiunta di 12 righe di event listener con fetch join calibrato sui gruppi di serializzazione, le query sono scese a 3 - 1 per la collezione con rate e locatari join-caricati, 2 per le relazioni ManyToMany ancora non ottimizzate ma irrilevanti per il throughput. Il tempo di risposta P95 è passato da 1.400 ms a 45 ms, un miglioramento di 30x fatto con mezza giornata di lavoro mirato, a deploy già avvenuto.
Un altro aspetto da tenere d'occhio è il costo della serializzazione pura. Per 200 entità con 20 attributi ognuna, Symfony Serializer usa tempo CPU significativo per riflettere le classi, applicare i gruppi, chiamare i normalizer. Su un endpoint ad alto traffico questo può diventare il bottleneck. La soluzione è abilitare la serialization cache di Symfony: precompilare i metadata dei DTO all'avvio dell'app e cachearli in Redis o APCu. Con la cache attiva, la serializzazione di 200 entità passa da 80 ms a 12 ms. Il cambio di configurazione è in config/packages/framework.yaml con due righe - un'ottimizzazione che il team novizio di API Platform tipicamente dimentica, con impatto significativo su traffico reale.
Evoluzione nel tempo: versioning, deprecation e migrazione
API Platform supporta nativamente il versioning delle API via attributi #[ApiResource] con normalizationContext e denormalizationContext differenziati per gruppo di versione. Il pattern che uso è 'groups' => ['v1:read', 'v1:list'] per la versione 1 e 'groups' => ['v2:read', 'v2:list'] per la versione 2, con attributi Doctrine che appartengono ai gruppi appropriati. Una singola entità Contratto può quindi esporre due versioni dell'API contemporaneamente, con differenze controllate sui campi esposti (v2 aggiunge un campo IBAN che v1 non aveva, v2 rinomina importoTotale in importoLordo aggiungendo anche importoNetto). La deprecation è supportata via attributo OpenAPI deprecated: true sull'operazione vecchia.
Il pattern di rollout che ho usato sul fintech è stato: lanciare v1 con i consumer iniziali, introdurre v2 come opzionale sei mesi dopo con i nuovi campi richiesti, marcare v1 come deprecata nella documentazione OpenAPI, comunicare ai client una deadline di 6 mesi per migrare, e dopo la deadline rimuovere v1. La procedura è stata smooth perché API Platform gestisce la coesistenza di versioni senza richiedere branching del codice o endpoint duplicati. Il pattern complementare per Laravel - dove il versioning API è gestito con pattern diversi - è descritto nel mio articolo sulle strategie pratiche di versioning API Laravel e la gestione della backward compatibility.
Bilancio del progetto fintech: quando la scelta architetturale paga
A 12 mesi dal deploy in produzione del SaaS fintech, le metriche misurabili sono queste. Il budget di sviluppo dell'API è stato rispettato al 95% (6 settimane pianificate, 6,2 settimane effettive inclusi due sprint di polishing). Il tempo di aggiunta di una nuova entità con CRUD standard e supporto di 4-5 filtri è sceso da una giornata (stimato se fatto a mano) a 2-3 ore con API Platform. Il numero di bug in produzione legati ad API è stato 4 in 12 mesi, tutti su logiche custom (custom controller, custom voter), zero bug sui CRUD generati automaticamente - che è esattamente quello che ci si aspetta quando un framework maturo gestisce la generazione. La documentazione OpenAPI è stata consumata direttamente dal team frontend per generare client TypeScript automatici, eliminando il lavoro di mantenere stub manuali.
Il risultato non è "API Platform è sempre la scelta migliore" - è "quando i tre criteri di applicabilità sono rispettati, API Platform abbassa il costo di costruzione e di manutenzione dell'API di un fattore 2-3x rispetto a scrivere tutto a mano con controller Symfony puri". Se hai un progetto Symfony con un dominio ricco di entità e pattern CRUD dominanti, e stai cercando un'accelerazione pragmatica senza sacrificare la qualità, API Platform merita considerazione. Se il tuo team è disposto a investire la settimana di onboarding sulle convenzioni del framework, l'investimento si ripaga a partire dal secondo mese di sviluppo. Se gestisci un progetto Symfony con API che cresce progressivamente e senti che stai scrivendo "lo stesso pattern CRUD per la ventesima volta", oppure stai pianificando un nuovo SaaS con dominio ricco e budget stretto, contattami per una valutazione architetturale: in una giornata analizzo il tuo dominio contro i tre criteri di applicabilità, stimo il beneficio effettivo calibrato sulla tua codebase, e ti consegno un piano di adozione o una motivazione onesta del perché non è la scelta giusta per te - evitandoti mesi persi in un'architettura che alla fine non scala al tuo caso specifico.