Générer automatiquement un JSON Schema depuis vos DTOs PHP avec Symfony Serializer
Dans un projet PHP moderne, les DTOs (Data Transfer Objects) sont au cœur de la sérialisation des données. Mais dès qu'on intègre un LLM avec du structured output, qu'on documente une API ou qu'on valide des payloads entrants, on a besoin d'un JSON Schema qui corresponde exactement à ce que produit le sérialiseur — pas à ce que dit le code PHP brut.
Le problème ? Symfony Serializer peut transformer profondément la forme JSON d'un DTO : renommage de propriétés, groupes de sérialisation, discriminateurs de types hérités... Un schéma écrit à la main devient vite obsolète dès qu'on touche à la configuration de sérialisation.
Cet article présente json-schema-extractor, une bibliothèque PHP qui génère automatiquement ce schéma en lisant les métadonnées déjà présentes dans vos DTOs.
Source originale : Generating JSON Schema from PHP DTOs with Symfony Serializer awareness par Antonio Turdo.
Le problème concret : le schéma dérive du code réel
Prenons un DTO simple :
final class OrderSummary
{
public function __construct(
#[Groups(['public'])]
#[SerializedName('order_id')]
public readonly string $id,
#[Groups(['public'])]
public readonly Money $total,
#[Groups(['internal'])]
public readonly string $internalNote,
) {}
}
Avec le groupe public, Symfony Serializer produit :
{
"order_id": "ORD-1042",
"total": {
"amount": 4999,
"currency": "EUR"
}
}
Trois transformations silencieuses ont eu lieu :
idest renommé enorder_idvia#[SerializedName]internalNotedisparaît (groupeinternalexclu)totaldevient un objet imbriqué
Un JSON Schema généré uniquement depuis les types PHP natifs sera faux sur ces trois points. Et si ce schéma alimente le structured output d'un LLM, les données retournées risquent de ne pas correspondre à ce que votre désérialiseur attend — source d'erreurs difficiles à diagnostiquer.
La solution : lire les métadonnées de sérialisation
json-schema-extractor résout ce problème en exploitant les mêmes sources d'information que Symfony Serializer lui-même :
- Les types natifs PHP (et les annotations PHPDoc pour les génériques)
- Les attributs Symfony :
#[Groups],#[SerializedName],#[DiscriminatorMap] - La configuration YAML/XML de sérialisation si elle existe
L'installation se fait via Composer :
composer require mtarld/json-schema-extractor
Pour une intégration Symfony complète, le bundle dédié est recommandé afin de profiter de l'injection de dépendances et de la configuration automatique.
Génération avec contexte de groupe
use MulerTech\JsonSchemaExtractor\SchemaExtractor;
$schema = $extractor->extract(OrderSummary::class, [
'groups' => ['public'],
]);
Résultat :
{
"type": "object",
"properties": {
"order_id": { "type": "string" },
"total": {
"type": "object",
"properties": {
"amount": { "type": "integer" },
"currency": { "type": "string" }
},
"required": ["amount", "currency"]
}
},
"required": ["order_id", "total"]
}
Le schéma reflète exactement ce que le sérialiseur produit : internalNote est absent, id est bien order_id.
Cas d'usage : structured output pour les LLM
C'est probablement l'usage le plus critique. Les APIs d'OpenAI, Anthropic ou Mistral acceptent un JSON Schema pour contraindre la réponse du modèle à une structure précise. Si ce schéma ne correspond pas à ce que votre désérialiseur attend, vous obtenez des erreurs à l'exécution — ou pire, des données silencieusement malformées.
Avec json-schema-extractor, le schéma envoyé au LLM et la logique de désérialisation sont issus de la même source de vérité : le DTO annoté.
// Génération du schéma pour le structured output
$schema = $extractor->extract(ProductAnalysis::class, ['groups' => ['llm_output']]);
// Envoi à l'API du LLM
$response = $llmClient->complete(
prompt: $userPrompt,
responseSchema: $schema,
);
// Désérialisation directe sans adaptation manuelle
$result = $serializer->deserialize(
$response->content,
ProductAnalysis::class,
'json',
['groups' => ['llm_output']]
);
Le cycle est fermé : le même groupe de sérialisation pilote à la fois la génération du schéma et la désérialisation de la réponse.
Autres fonctionnalités notables
Support des types hérités avec discriminateur :
#[DiscriminatorMap(typeProperty: 'type', mapping: [
'card' => CardPayment::class,
'transfer' => BankTransfer::class,
])]
abstract class Payment {}
La bibliothèque génère un schéma avec oneOf et la propriété discriminante, ce qui permet aux validateurs JSON Schema de traiter correctement les unions de types.
Support des collections typées via PHPDoc :
/** @param list<OrderLine> $lines */
public readonly array $lines,
Le type générique list<OrderLine> est interprété pour produire un schéma array avec items pointant vers le schéma de OrderLine.
Intégration avec API Platform :
Dans un contexte API Platform, les groupes de sérialisation varient selon l'opération (GET, POST, PUT...). json-schema-extractor peut générer un schéma différent par opération, en passant simplement les groupes correspondants.
Conclusion
Maintenirs manuellement un JSON Schema en synchronisation avec des DTOs Symfony annotés est une dette technique certaine. Chaque modification de groupe, de nom sérialisé ou de structure de type peut invalider silencieusement le contrat.
json-schema-extractor élimine ce problème en faisant du DTO la source de vérité unique : le JSON Schema est dérivé des mêmes métadonnées que celles lues par le sérialiseur.
Pour les équipes qui intègrent des LLM avec du structured output, c'est particulièrement pertinent : un schéma désynchronisé avec le désérialiseur produit des bugs difficiles à tracer. Centraliser cette logique dans le DTO, c'est réduire la surface d'erreur et simplifier la maintenance.
La bibliothèque est disponible sur Packagist. La documentation complète couvre les cas avancés (union types, nullable, formats personnalisés) et l'intégration avec le bundle Symfony.