<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Antonio Turdo</title>
    <description>The latest articles on DEV Community by Antonio Turdo (@antonioturdo).</description>
    <link>https://dev.to/antonioturdo</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3972922%2F742cec2a-6c98-420f-a5f2-a59eb61ac6c2.png</url>
      <title>DEV Community: Antonio Turdo</title>
      <link>https://dev.to/antonioturdo</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/antonioturdo"/>
    <language>en</language>
    <item>
      <title>Your Symfony events have a contract: generate the AsyncAPI docs from your code</title>
      <dc:creator>Antonio Turdo</dc:creator>
      <pubDate>Thu, 02 Jul 2026 05:58:12 +0000</pubDate>
      <link>https://dev.to/antonioturdo/your-symfony-events-have-a-contract-generate-the-asyncapi-docs-from-your-code-3lh2</link>
      <guid>https://dev.to/antonioturdo/your-symfony-events-have-a-contract-generate-the-asyncapi-docs-from-your-code-3lh2</guid>
      <description>&lt;p&gt;A Symfony app that exposes an HTTP API can have that API documented directly from the code: tooling reads the controllers and routes and produces an OpenAPI spec. The framework knows every endpoint, so the documentation is derived from the code rather than maintained alongside it.&lt;/p&gt;

&lt;p&gt;Event-driven systems have contracts too, which messages flow, what their payload looks like, where they go, but nothing in the framework enumerates them. No generator reads the messages off the framework the way it reads HTTP routes, so often the contract is either left undocumented or written and maintained by hand.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.asyncapi.com" rel="noopener noreferrer"&gt;AsyncAPI&lt;/a&gt; is the standard that fills the gap: like OpenAPI, but for messages over a broker (AMQP, Kafka, SQS, …) instead of REST APIs. &lt;a href="https://packagist.org/packages/zeusi/asyncapi-bundle" rel="noopener noreferrer"&gt;&lt;code&gt;zeusi/asyncapi-bundle&lt;/code&gt;&lt;/a&gt; is a Symfony bundle that generates AsyncAPI 3.x documentation from your application's code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an AsyncAPI document describes
&lt;/h2&gt;

&lt;p&gt;An AsyncAPI document captures, in a machine-readable spec:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;messages&lt;/strong&gt;, the events/commands that flow, each with a &lt;strong&gt;payload schema&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;channels&lt;/strong&gt;, the addressable medium they flow through;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;operations&lt;/strong&gt;, whether your app &lt;em&gt;sends&lt;/em&gt; or &lt;em&gt;receives&lt;/em&gt; each message;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;servers&lt;/strong&gt;, the brokers involved, and their protocol.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once it exists, you get the same things OpenAPI gives you for REST: a browsable UI, a contract consumers can code against, schema validation, and a single source of truth that can live next to the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why events are harder to document than a REST API
&lt;/h2&gt;

&lt;p&gt;REST tooling has a &lt;strong&gt;natural anchor&lt;/strong&gt;: routes. Every endpoint is registered with the framework, so a generator can enumerate controllers and reflect their input/output. There's a definitive list to walk.&lt;/p&gt;

&lt;p&gt;An event-driven app has no equivalent. A message might reach the broker through a client library, a vendor SDK, or a plain HTTP call, there's no single point the framework owns, so nothing holds a registry that says "these are the events this service publishes, and this is their shape". AsyncAPI itself doesn't impose one either. So the document is either written by hand and kept in sync manually, or derived from a convention the tooling can read.&lt;/p&gt;

&lt;p&gt;This bundle takes the pragmatic route, on one assumption: that each message is represented by a PHP class, a DTO. The class is the anchor: the payload schema is derived from it, so the documentation can't drift from the code. The remaining question, which classes are messages, is answered, by default, with an attribute on the class, which also carries the metadata the code can't infer, like the channel a message belongs to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installation and setup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require zeusi/asyncapi-bundle
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Minimal configuration is just your document's &lt;code&gt;info&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/packages/asyncapi.yaml&lt;/span&gt;
&lt;span class="na"&gt;asyncapi&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;document&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Demo&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Events'&lt;/span&gt;
      &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1.0.0'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the routes for the two endpoints (more on those below):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/routes/asyncapi.yaml&lt;/span&gt;
&lt;span class="na"&gt;asyncapi&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;@AsyncApiBundle/Controller/AsyncApiController.php'&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;attribute&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole setup. Now let's document something.&lt;/p&gt;

&lt;h2&gt;
  
  
  Declaring a message
&lt;/h2&gt;

&lt;p&gt;Mark the DTO that represents your event with &lt;code&gt;#[AsyncApiMessage]&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Zeusi\AsyncApiBundle\Attribute\AsyncApiMessage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[AsyncApiMessage(channel: 'bookings', summary: 'A trip was booked', tags: ['travel'])]&lt;/span&gt;
&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TripBooked&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$bookingId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$travelers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bundle scans your code for the attribute, derives each payload schema from the class, and assembles a valid AsyncAPI 3.x document: a &lt;code&gt;message&lt;/code&gt; in &lt;code&gt;components.messages&lt;/code&gt;, grouped into a &lt;code&gt;channel&lt;/code&gt;, with a send &lt;code&gt;operation&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The schema derivation is handled by &lt;a href="https://github.com/antonioturdo/json-schema-extractor" rel="noopener noreferrer"&gt;&lt;code&gt;zeusi/json-schema-extractor&lt;/code&gt;&lt;/a&gt;: it reflects the class and asks a serialization strategy how the object goes over the wire. With the Symfony Serializer strategy (used when the Serializer is available), the schema reflects the shape you actually send, serialization groups, &lt;code&gt;#[SerializedName]&lt;/code&gt;, name converters, discriminators. PHPDoc and Validator constraints enrich it further when those packages are present.&lt;/p&gt;

&lt;p&gt;The attribute carries only the things the code &lt;em&gt;can't&lt;/em&gt; tell you, placement and human metadata (title, summary, tags, …). Everything structural comes from the class. Because the schema is derived, it can't fall out of sync with the DTO.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exposing the document and UI
&lt;/h2&gt;

&lt;p&gt;The bundle exposes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;GET /asyncapi.json&lt;/code&gt;&lt;/strong&gt;, the generated document as JSON. Always available, and
the thing your consumers (or a contract test) actually fetch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;GET /asyncapi&lt;/code&gt;&lt;/strong&gt;, an HTML UI rendered with the official AsyncAPI web component, so humans get a browsable view (this one needs Twig, install &lt;code&gt;symfony/twig-bundle&lt;/code&gt; to enable it).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhsauefmogirhlanpssfs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhsauefmogirhlanpssfs.png" alt=" " width="800" height="402"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's the attribute workflow end to end: annotate the events, get a contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Symfony Messenger integration
&lt;/h2&gt;

&lt;p&gt;Symfony has no first-class notion of "a published event" to hook into. The closest thing is &lt;strong&gt;Messenger&lt;/strong&gt;, it's where transports, routing and serialization are already declared, which makes it the one sensible anchor in the ecosystem. So the bundle can read your Messenger setup to fill in the parts of the document that live in your infrastructure, not in your DTOs.&lt;/p&gt;

&lt;p&gt;It reuses Messenger's &lt;em&gt;own&lt;/em&gt; routing semantics, the same type matching Messenger uses to pick a transport for a message, so what it documents matches where your messages actually go. There are two capabilities, both &lt;strong&gt;opt-in&lt;/strong&gt;, configured under &lt;code&gt;providers.messenger&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;asyncapi&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;messenger&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;transports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;events'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# allowlist; scopes everything below&lt;/span&gt;
      &lt;span class="na"&gt;enrichment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;discovery&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Enrichment: servers and content types
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;enrichment: true&lt;/code&gt;, for each documented message routed to a transport the bundle adds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;servers&lt;/strong&gt;, derived from the transport DSN (host + protocol);&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;content type&lt;/strong&gt;, taken from the transport's serializer (the Symfony Serializer yields &lt;code&gt;application/json&lt;/code&gt;), unless the message declared one;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;channel ↔ server links&lt;/strong&gt;, when more than one server exists, so each channel is pinned to the servers its messages actually use.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Internal transports (&lt;code&gt;sync://&lt;/code&gt;, &lt;code&gt;in-memory://&lt;/code&gt;, &lt;code&gt;doctrine://&lt;/code&gt;) and failure/retry transports are left out automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Discovery: messages from the routing map
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;discovery: true&lt;/code&gt;, the routing map itself becomes a source of messages, so classes you've &lt;strong&gt;already routed&lt;/strong&gt; don't need the attribute at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/packages/messenger.yaml&lt;/span&gt;
&lt;span class="na"&gt;framework&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;messenger&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;routing&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;App\Message\TripBooked'&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;events&lt;/span&gt;   &lt;span class="c1"&gt;# already here for your app to work&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bundle treats the routing keys (and classes matched by interface, parent or namespace-wildcard rules) as messages, deriving their payloads from the class just like the attribute path does. The &lt;code&gt;*&lt;/code&gt; catch-all is never a source, it matches every dispatched object and can't be enumerated, and anything you &lt;em&gt;did&lt;/em&gt; annotate stays owned by the attribute, so its richer metadata wins.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the integration is opt-in
&lt;/h3&gt;

&lt;p&gt;Both capabilities rest on an assumption: that the Messenger transports &lt;em&gt;are&lt;/em&gt; the publication channel. That doesn't always hold. Messenger might be used purely for internal queues, async jobs that never leave the app, or a message might pass through an internal Messenger queue and then be published by some other mechanism the bundle can't see. In either case, documenting the Messenger transport as a public server would be misleading.&lt;/p&gt;

&lt;p&gt;The payload extraction follows from the code and makes no assumption about how you publish. The Messenger integration, by contrast, makes a semantic guess, and a guess that can write wrong information into a public contract should be a conscious choice, not a default. Turning it on is your assertion that, for the documented transports, Messenger is how the event goes out. The &lt;code&gt;transports&lt;/code&gt; allowlist is there to scope it precisely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Generating documentation from code is what keeps it from going stale, and this bundle applies that idea to the messages a service publishes in a distributed system. Payload schemas come from your DTOs; messages are discovered from an attribute or from your Messenger configuration, which the bundle can also read to derive the brokers involved. The documentation moves when the code moves, instead of being a separate artifact to maintain.&lt;/p&gt;

&lt;p&gt;The bundle is young, pre-1.0, so the API may still shift between minor versions. &lt;br&gt;
The attribute-based workflow is the stable core, while the Messenger integration is there to draw on when it makes sense for your system.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Package: &lt;a href="https://packagist.org/packages/zeusi/asyncapi-bundle" rel="noopener noreferrer"&gt;&lt;code&gt;zeusi/asyncapi-bundle&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source &amp;amp; docs: &lt;a href="https://github.com/antonioturdo/asyncapi-bundle" rel="noopener noreferrer"&gt;github.com/antonioturdo/asyncapi-bundle&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you document events by hand today, give it a try, and if it's missing something your setup needs, issues and PRs are welcome.&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>php</category>
      <category>asyncapi</category>
      <category>eventdriven</category>
    </item>
    <item>
      <title>Generating JSON Schema from PHP DTOs with Symfony Serializer awareness</title>
      <dc:creator>Antonio Turdo</dc:creator>
      <pubDate>Sun, 07 Jun 2026 19:08:20 +0000</pubDate>
      <link>https://dev.to/antonioturdo/generating-json-schema-from-php-dtos-with-symfony-serializer-awareness-459o</link>
      <guid>https://dev.to/antonioturdo/generating-json-schema-from-php-dtos-with-symfony-serializer-awareness-459o</guid>
      <description>&lt;p&gt;A PHP project that serializes and deserializes DTOs often needs a JSON Schema for them — for an LLM's structured output, for API documentation, or to validate incoming payloads. &lt;/p&gt;

&lt;p&gt;Writing it by hand works until the code changes. With Symfony Serializer, the JSON shape can shift without the PHP types changing at all — a new serialization group, a &lt;code&gt;#[SerializedName]&lt;/code&gt;, a discriminator — and the hand-written schema no longer matches what the serializer produces.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/antonioturdo/json-schema-extractor" rel="noopener noreferrer"&gt;json-schema-extractor&lt;/a&gt; is a PHP library that generates JSON Schema from DTOs by reading the metadata they already carry — native types, PHPDoc, and serializer configuration — so the schema stays in sync with the code. It supports plain &lt;code&gt;json_encode&lt;/code&gt;/&lt;code&gt;JsonSerializable&lt;/code&gt; and Symfony Serializer; this article focuses on the latter.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem in concrete terms
&lt;/h2&gt;

&lt;p&gt;Take a simple DTO:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderSummary&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="na"&gt;#[Groups(['public'])]&lt;/span&gt;
        &lt;span class="na"&gt;#[SerializedName('order_id')]&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

        &lt;span class="na"&gt;#[Groups(['public'])]&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;Money&lt;/span&gt; &lt;span class="nv"&gt;$total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

        &lt;span class="na"&gt;#[Groups(['internal'])]&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$internalNote&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When Symfony Serializer renders this with the &lt;code&gt;public&lt;/code&gt; group, the JSON output is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; 
  &lt;/span&gt;&lt;span class="nl"&gt;"order_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ORD-1042"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; 
  &lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; 
    &lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4999&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; 
    &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"EUR"&lt;/span&gt;&lt;span class="w"&gt; 
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;internalNote&lt;/code&gt; is absent. &lt;code&gt;id&lt;/code&gt; is renamed. &lt;code&gt;total&lt;/code&gt; is a nested object. A hand-written schema that matches this needs to know about groups, serialized names, and how &lt;code&gt;Money&lt;/code&gt; is normalized. If any of those change, you update them in two places.&lt;/p&gt;




&lt;h2&gt;
  
  
  How the library works
&lt;/h2&gt;

&lt;p&gt;The extraction pipeline has four phases, each with a clear responsibility:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Discover&lt;/strong&gt; — &lt;code&gt;ReflectionDiscoverer&lt;/code&gt; reads the class with reflection: property names, native PHP types, visibility. No dependencies required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Enrich&lt;/strong&gt; — one or more enrichers augment the PHP model with metadata reflection alone can't see. &lt;code&gt;PhpStanEnricher&lt;/code&gt; and &lt;code&gt;PhpDocumentorEnricher&lt;/code&gt; both read PHPDoc (&lt;code&gt;@var list&amp;lt;string&amp;gt;&lt;/code&gt;, &lt;code&gt;@var array{name: string, age: int}&lt;/code&gt;, generics, descriptions, deprecation), via different parsers. &lt;code&gt;SymfonyValidationEnricher&lt;/code&gt; maps Symfony Validator constraints (&lt;code&gt;NotBlank&lt;/code&gt;, &lt;code&gt;Length&lt;/code&gt;, &lt;code&gt;Range&lt;/code&gt;…) to their JSON Schema equivalents — appropriate when the application actually validates the objects against those constraints, so the schema's guarantees hold for the real data. Enrichers are optional and composable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Project&lt;/strong&gt; — a serialization strategy converts the enriched PHP model into the &lt;em&gt;serialized shape&lt;/em&gt;: the JSON-facing view of the class. The Symfony Serializer support is implemented in this phase.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Map&lt;/strong&gt; — &lt;code&gt;StandardJsonSchemaMapper&lt;/code&gt; folds the projected shape into a JSON Schema document, handling &lt;code&gt;$ref&lt;/code&gt;, reusable definitions, dialect (&lt;code&gt;draft-7&lt;/code&gt; or &lt;code&gt;2020-12&lt;/code&gt;), and union semantics.&lt;/p&gt;

&lt;p&gt;The four phases are wired together by &lt;code&gt;SchemaExtractor&lt;/code&gt;, the entry point you call to produce a schema.&lt;/p&gt;

&lt;p&gt;Each phase is defined by an interface — &lt;code&gt;DiscovererInterface&lt;/code&gt;, &lt;code&gt;EnricherInterface&lt;/code&gt;, &lt;code&gt;SerializationStrategyInterface&lt;/code&gt;, &lt;code&gt;JsonSchemaMapperInterface&lt;/code&gt; — so every component can be swapped or extended. You can plug in your own discoverer, enricher, strategy, or mapper without touching the rest.&lt;/p&gt;




&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;

&lt;p&gt;Install the library, then add only the optional packages your chosen components need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require zeusi/json-schema-extractor

&lt;span class="c"&gt;# optional, depending on what you enable:&lt;/span&gt;
composer require phpstan/phpdoc-parser              &lt;span class="c"&gt;# for PhpStanEnricher&lt;/span&gt;
composer require phpdocumentor/reflection-docblock  &lt;span class="c"&gt;# for PhpDocumentorEnricher&lt;/span&gt;
composer require symfony/validator                  &lt;span class="c"&gt;# for SymfonyValidationEnricher&lt;/span&gt;
composer require symfony/serializer                 &lt;span class="c"&gt;# for SymfonySerializerStrategy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The core package itself has no mandatory dependencies.&lt;/p&gt;




&lt;h2&gt;
  
  
  A minimal extractor
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Zeusi\JsonSchemaExtractor\Discoverer\ReflectionDiscoverer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Zeusi\JsonSchemaExtractor\Enricher\PhpStanEnricher&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Zeusi\JsonSchemaExtractor\Mapper\StandardJsonSchemaMapper&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Zeusi\JsonSchemaExtractor\SchemaExtractor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Zeusi\JsonSchemaExtractor\Serialization\JsonEncodeSerializationStrategy&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$extractor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SchemaExtractor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ReflectionDiscoverer&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PhpStanEnricher&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JsonEncodeSerializationStrategy&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StandardJsonSchemaMapper&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$extractor&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderSummary&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives you a schema based on native PHP types and PHPDoc. &lt;code&gt;JsonEncodeSerializationStrategy&lt;/code&gt; is the right choice when your JSON is produced by &lt;code&gt;json_encode()&lt;/code&gt; or &lt;code&gt;JsonSerializable&lt;/code&gt; (in that case, the shape is read from &lt;code&gt;jsonSerialize()&lt;/code&gt;'s return type and PHPDoc, since its body is opaque to static analysis).&lt;/p&gt;

&lt;p&gt;For Symfony Serializer, you swap the strategy.&lt;/p&gt;




&lt;h2&gt;
  
  
  Adding Symfony Serializer awareness
&lt;/h2&gt;

&lt;p&gt;To make the schema follow Symfony Serializer instead of &lt;code&gt;json_encode()&lt;/code&gt;, swap in &lt;code&gt;SymfonySerializerStrategy&lt;/code&gt;. Runtime serializer context — serialization groups, for example — is passed to &lt;code&gt;extract()&lt;/code&gt; through an &lt;code&gt;ExtractionContext&lt;/code&gt;, which is optional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Serializer\Mapping\Loader\AttributeLoader&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Zeusi\JsonSchemaExtractor\Context\ExtractionContext&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Zeusi\JsonSchemaExtractor\Context\SymfonySerializerContext&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Zeusi\JsonSchemaExtractor\Serialization\SymfonySerializerStrategy&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$strategy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SymfonySerializerStrategy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ClassMetadataFactory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AttributeLoader&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$extractor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SchemaExtractor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ReflectionDiscoverer&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PhpStanEnricher&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
    &lt;span class="nv"&gt;$strategy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StandardJsonSchemaMapper&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Optional: a context carrying the runtime serializer settings (here, the "public" group).&lt;/span&gt;
&lt;span class="nv"&gt;$context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ExtractionContext&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SymfonySerializerContext&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'groups'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;]));&lt;/span&gt;

&lt;span class="nv"&gt;$schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$extractor&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderSummary&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For &lt;code&gt;OrderSummary&lt;/code&gt; with the &lt;code&gt;public&lt;/code&gt; group, the schema matches what Symfony Serializer produces: &lt;code&gt;internalNote&lt;/code&gt; is absent, and &lt;code&gt;id&lt;/code&gt; appears as &lt;code&gt;order_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The strategy reads the same inputs Symfony Serializer reads at runtime — both the attributes declared on the DTO and the options passed in the serializer context (the array shown above, where &lt;code&gt;groups&lt;/code&gt; lives):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;#[SerializedName]&lt;/code&gt; / name converters&lt;/strong&gt; → property keys are renamed in the schema.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serialization groups&lt;/strong&gt; (&lt;code&gt;groups&lt;/code&gt; context option) → only properties in the selected groups appear.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ignored_attributes&lt;/code&gt;&lt;/strong&gt; (context option) → the listed properties are excluded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;attributes&lt;/code&gt;&lt;/strong&gt; (context option) → restricts the schema to the listed attributes, with per-property nested views for class-backed ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Discriminator maps&lt;/strong&gt; (&lt;code&gt;#[DiscriminatorMap]&lt;/code&gt;) → a base type expands to a &lt;code&gt;oneOf&lt;/code&gt; over its mapped subtypes, each tagged with the discriminator field; a concrete subtype is a single object with that field fixed to its key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Known normalizers&lt;/strong&gt; (by type) → &lt;code&gt;DateTimeInterface&lt;/code&gt; becomes &lt;code&gt;{ type: string, format: date-time }&lt;/code&gt;, Symfony UIDs become &lt;code&gt;{ type: string, format: uuid }&lt;/code&gt;, and so on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;skip_null_values&lt;/code&gt;&lt;/strong&gt; (context option) → nullable properties become optional in the schema.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What it does not model
&lt;/h2&gt;

&lt;p&gt;The strategy reads &lt;strong&gt;static metadata&lt;/strong&gt; — attributes and types — so it cannot mirror everything Symfony Serializer does at runtime. It is not a 1:1 mapping of the serializer's behaviour. The gaps worth knowing about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;preserve_empty_objects&lt;/code&gt;&lt;/strong&gt; (context option) → serializes an empty collection as &lt;code&gt;{}&lt;/code&gt; instead of &lt;code&gt;[]&lt;/code&gt;; whether it applies depends on the runtime value, which static analysis can't see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;skip_uninitialized_values&lt;/code&gt;&lt;/strong&gt; (context option) → omits typed properties that were never assigned; that's a fact about the object instance at runtime, not about the class.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom normalizers&lt;/strong&gt; outside the known set → their output shape is defined in application code, so it can't be inferred.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;max_depth_handler&lt;/code&gt; / &lt;code&gt;circular_reference_handler&lt;/code&gt;&lt;/strong&gt; (context options) → callables that replace a node when the depth limit or a cycle is hit; their output is arbitrary application code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;#[MaxDepth]&lt;/code&gt;&lt;/strong&gt; → not modeled as a tightened schema, and doesn't need to be: recursion is already broken with a &lt;code&gt;$ref&lt;/code&gt;, and a depth-bounded payload is a valid instance of that (looser) recursive schema.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interfaces / polymorphic base types&lt;/strong&gt; → an interface has no single concrete shape, so it can't be resolved on its own. A Symfony &lt;code&gt;#[DiscriminatorMap]&lt;/code&gt; turns it into a &lt;code&gt;oneOf&lt;/code&gt; when the payload carries a type field; otherwise a custom strategy or enricher can supply the concrete shapes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a case isn't covered, the serialization strategy is the extension point: implement &lt;code&gt;SerializationStrategyInterface&lt;/code&gt;, or decorate &lt;code&gt;SymfonySerializerStrategy&lt;/code&gt;, and handle it there.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reusable definitions and $ref
&lt;/h2&gt;

&lt;p&gt;By default, class-backed nested types are emitted once under &lt;code&gt;definitions&lt;/code&gt; (Draft-7) or &lt;code&gt;$defs&lt;/code&gt; (2020-12) and referenced with &lt;code&gt;$ref&lt;/code&gt; everywhere they are used. If you prefer the nested schemas expanded at the point of use instead, switch to &lt;code&gt;ClassReferenceStrategy::Inline&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Zeusi\JsonSchemaExtractor\Mapper\ClassReferenceStrategy&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Zeusi\JsonSchemaExtractor\Mapper\StandardJsonSchemaMapper&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Zeusi\JsonSchemaExtractor\Mapper\StandardJsonSchemaMapperOptions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$mapper&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StandardJsonSchemaMapper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StandardJsonSchemaMapperOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;classReferenceStrategy&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ClassReferenceStrategy&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Inline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Circular references are handled automatically either way: a self-referential class produces a &lt;code&gt;$ref&lt;/code&gt; back to the root (&lt;code&gt;#&lt;/code&gt;) or to the relevant definition, rather than an infinite expansion.&lt;/p&gt;




&lt;h2&gt;
  
  
  Symfony Bundle
&lt;/h2&gt;

&lt;p&gt;If you use the Symfony framework, a bundle registers the built-in components as services and wires the extractor into the container. Its main convenience is reusing the &lt;strong&gt;Symfony Serializer and Validator&lt;/strong&gt; services your application already has — so you don't assemble a &lt;code&gt;ClassMetadataFactory&lt;/code&gt;, a validator, and the strategy/enricher wiring by hand.&lt;/p&gt;

&lt;p&gt;You declare one or more extractor pipelines, choosing the strategy that matches how your app serializes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/packages/json_schema_extractor.yaml&lt;/span&gt;
&lt;span class="na"&gt;json_schema_extractor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;default_extractor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;
  &lt;span class="na"&gt;extractors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;enrichers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;json_schema_extractor.enricher.phpstan&lt;/span&gt;
      &lt;span class="na"&gt;serialization&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;json_schema_extractor.serialization.symfony_serializer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SchemaExtractor&lt;/code&gt; is aliased to the default extractor, so you inject it directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;SchemaExtractor&lt;/span&gt; &lt;span class="nv"&gt;$extractor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="nv"&gt;$schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;extractor&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderSummary&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;See the &lt;a href="https://github.com/antonioturdo/json-schema-extractor/blob/main/docs/symfony-bundle.md" rel="noopener noreferrer"&gt;bundle documentation&lt;/a&gt; for multiple pipelines, custom services, and the debug command.&lt;/p&gt;




&lt;h2&gt;
  
  
  Use case: structured output for LLMs
&lt;/h2&gt;

&lt;p&gt;A common use today is structured output from an LLM. Most providers accept a JSON Schema as the contract for the model's response: you generate the schema from a DTO, send it with the request, and deserialize the response back into that same DTO.&lt;/p&gt;

&lt;p&gt;Since the schema comes from the DTO you deserialize into, the two stay in sync as the DTO changes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;p&gt;The library is open source under the MIT license: &lt;a href="https://github.com/antonioturdo/json-schema-extractor" rel="noopener noreferrer"&gt;github.com/antonioturdo/json-schema-extractor&lt;/a&gt;. The documentation covers all enrichers, serialization strategies, mapper options, and the Symfony bundle in detail.&lt;/p&gt;

&lt;p&gt;If you are building something with it — structured output pipelines, AsyncAPI documentation, API contract testing — feedback and contributions are welcome.&lt;/p&gt;

</description>
      <category>php</category>
      <category>symfony</category>
      <category>jsonschema</category>
    </item>
  </channel>
</rss>
