<?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: David Evdoshchenko</title>
    <description>The latest articles on DEV Community by David Evdoshchenko (@outcomer).</description>
    <link>https://dev.to/outcomer</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%2F3808514%2F4e8986ae-073e-4785-995f-39747ad696b1.jpg</url>
      <title>DEV Community: David Evdoshchenko</title>
      <link>https://dev.to/outcomer</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/outcomer"/>
    <language>en</language>
    <item>
      <title>Six Months Later, the Bundle Is Still Alive</title>
      <dc:creator>David Evdoshchenko</dc:creator>
      <pubDate>Thu, 03 Sep 2026 12:18:36 +0000</pubDate>
      <link>https://dev.to/outcomer/six-months-later-the-bundle-is-still-alive-257j</link>
      <guid>https://dev.to/outcomer/six-months-later-the-bundle-is-still-alive-257j</guid>
      <description>&lt;p&gt;So, it's been half a year since the first stable version of &lt;a href="https://outcomer.github.io/symfony-json-schema-validation/" rel="noopener noreferrer"&gt;Symfony JSON Schema Validation Bundle&lt;/a&gt; was released, which turned out to be version 3.&lt;/p&gt;

&lt;p&gt;Someone is actually using the bundle consistently. It already has around 150 installations, and that number keeps growing every day. Probably even every hour. Most likely, these aren't 150 different users - a significant part of the installations probably comes from CI/CD. But why not? That's good too.&lt;/p&gt;

&lt;p&gt;Does that mean I was right about something? Does it mean that you really don't always have to follow all the best practices from beginning to end?&lt;/p&gt;

&lt;p&gt;Let me remind you - in an ideal world, the business knows its business processes. You can describe every entity exhaustively, including its parameters and rules, create models for everything, derive validation rules from those models, throw something like API Platform on top of it, and get one f*cking amazing API.&lt;/p&gt;

&lt;p&gt;From my previous posts in this series, you may have already figured out that I got tired of this ideal world and decided to take the JSON Schema standard and apply it as a Symfony bundle.&lt;/p&gt;





&lt;center&gt;
&lt;strong&gt;So apparently, I'm not the only one who got bloody tired of all this!!&lt;/strong&gt;
&lt;/center&gt;





&lt;p&gt;Since the code somehow acquired a life of its own, I decided to spend some of my available free time on it. If we speak dryly, here is the &lt;a href="https://github.com/outcomer/symfony-json-schema-validation/blob/v4.0.1/CHANGELOG.md" rel="noopener noreferrer"&gt;changelog&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;But if we speak like normal human beings, I fixed several problems and significantly reconsidered some parts of the bundle. Every class was touched by the changes, but the paradigm itself remained &lt;strong&gt;untouched&lt;/strong&gt;. Everything should be simple: &lt;em&gt;data → schema → validation → understandable response to the client.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The biggest change is &lt;strong&gt;support for long-running PHP runtimes such as Swoole and RoadRunner&lt;/strong&gt;. You can now load it into an application running under &lt;strong&gt;Swoole&lt;/strong&gt;, for example, and state will not leak between coroutines or threads. At the same time, the schema resolver is now reused instead of being created from scratch for every validation. I personally think that the PHP Swoole extension should be included in PHP itself. Hey, PHP core developers - why the hell isn't that already the case?! Take my weird &lt;a href="https://schrodinger.svcpool.com/" rel="noopener noreferrer"&gt;Schrödinger website&lt;/a&gt;, for example. It uses a classic stack: PHP, Symfony - with all the usual stuff like Doctrine, Monolog, Twig and MySQL. But PHP is running with the Swoole extension. The average server response time is 50 ms. That's the level of compiled languages.&lt;/p&gt;

&lt;p&gt;Sorry, I got distracted.&lt;/p&gt;

&lt;p&gt;JSON Schemas can now also be registered from different sources with different paths and domains. So you can create a schema for an email address with your own specific rules and then simply reference it in 100500 different places. Go ahead and try doing that using DTOs.))) Haha.&lt;/p&gt;

&lt;p&gt;There are some other small things as well, but &lt;strong&gt;nothing revolutionary&lt;/strong&gt;. It's just code that I originally wrote for myself that suddenly started being used by someone else. And that made me want to make it a little better.&lt;/p&gt;

&lt;p&gt;Guys and girls, support me. Come on, don't be boring, give a star to repo or try the package.&lt;/p&gt;





&lt;center&gt;
&lt;strong&gt;Enthusiasm doesn't live for very long on its own.&lt;/strong&gt;

&lt;/center&gt;

&lt;p&gt;Bundle: &lt;a href="https://github.com/outcomer/symfony-json-schema-validation" rel="noopener noreferrer"&gt;https://github.com/outcomer/symfony-json-schema-validation&lt;/a&gt;&lt;br&gt;
Docs: &lt;a href="https://outcomer.github.io/symfony-json-schema-validation/" rel="noopener noreferrer"&gt;https://outcomer.github.io/symfony-json-schema-validation/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A special thanks to &lt;a class="mentioned-user" href="https://dev.to/javiereguiluz"&gt;@javiereguiluz&lt;/a&gt;. Apparently, it was probably thanks to him that the bundle made it into &lt;a href="https://symfony.com/blog/a-week-of-symfony-1013-may-25-31-2026" rel="noopener noreferrer"&gt;Symfony News&lt;/a&gt; and got a chance to live a little.&lt;/p&gt;





&lt;p&gt;P.S. I also became a Symfony contributor. I've always wondered how people become contributors... Apparently, this is how... You write something, and then it turns out that there's a small thing in the framework or ecosystem that is critical for you, so you go and fix it (In my case, it was something in the Symfony Flex pipeline.). And then one day you wake up and - boom... you're a contributor.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/sponsors/outcomer" rel="noopener noreferrer"&gt;❤️ Support me on GitHub Sponsors&lt;/a&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>symfony</category>
      <category>swoole</category>
      <category>api</category>
    </item>
    <item>
      <title>Schrödinger's website exists</title>
      <dc:creator>David Evdoshchenko</dc:creator>
      <pubDate>Mon, 31 Aug 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/outcomer/schrodingers-website-exists-4kn0</link>
      <guid>https://dev.to/outcomer/schrodingers-website-exists-4kn0</guid>
      <description>&lt;p&gt;I'm fed up with everything. Smart guys. Patterns. Algorithms. HR managers. Soft skills. Hard skills. And all that opium.&lt;/p&gt;

&lt;p&gt;The world has developed a split personality. One side pretends AI doesn't exist, while the other actively uses it.&lt;/p&gt;

&lt;p&gt;And to escape this misery, I created &lt;strong&gt;Schrödinger's website&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I'm obviously not as brilliant as the Master, so there's no cat in my box. There's a story written by AI instead.&lt;/p&gt;





&lt;center&gt;
&lt;p&gt;🔗 &lt;a href="https://schrodinger.svcpool.com/" rel="noopener noreferrer"&gt;&lt;strong&gt;schrodinger.svcpool.com&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/center&gt;





&lt;p&gt;There's one thing that wasn't part of the original experiment: you can put a message into the box that will influence the next story.&lt;/p&gt;

&lt;p&gt;So the box is opened, the story is read, a message is put inside, and the box is closed. The next time the box is opened, that message will somehow influence what happens. I have no idea how.&lt;/p&gt;

&lt;p&gt;So, girls &amp;amp; guys, don't be boring. Go here &lt;a href="https://schrodinger.svcpool.com/story" rel="noopener noreferrer"&gt;https://schrodinger.svcpool.com/story&lt;/a&gt; and generate a couple of stories. I'm genuinely curious to see what AI is capable of without direct human involvement.&lt;/p&gt;





&lt;blockquote&gt;
&lt;p&gt;💸 If you want the experiment to continue, you can throw a few bucks my way for tokens here: &lt;a href="https://schrodinger.svcpool.com/statistics" rel="noopener noreferrer"&gt;&lt;strong&gt;schrodinger.svcpool.com/statistics&lt;/strong&gt;&lt;/a&gt;. Whatever you can spare.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>ai</category>
      <category>web</category>
      <category>socialmedia</category>
      <category>education</category>
    </item>
    <item>
      <title>I Stopped Following API Validation Best Practices. Here's Why.</title>
      <dc:creator>David Evdoshchenko</dc:creator>
      <pubDate>Fri, 12 Jun 2026 16:14:33 +0000</pubDate>
      <link>https://dev.to/outcomer/i-stopped-following-api-validation-best-practices-heres-why-1fmj</link>
      <guid>https://dev.to/outcomer/i-stopped-following-api-validation-best-practices-heres-why-1fmj</guid>
      <description>&lt;p&gt;For years, I followed what most Symfony developers would call best practices. A new API endpoint appeared in the backlog, and the process was always the same:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Design the domain model&lt;/li&gt;
&lt;li&gt;Create DTOs&lt;/li&gt;
&lt;li&gt;Add validation constraints&lt;/li&gt;
&lt;li&gt;Connect everything together&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It looked clean and professional. It looked exactly like the examples I saw in conference talks, blog posts, and framework documentation.&lt;/p&gt;

&lt;p&gt;And it almost &lt;strong&gt;never&lt;/strong&gt; survived contact with reality.&lt;/p&gt;

&lt;p&gt;Not because Symfony was wrong, not because DTOs were bad, but because the projects I worked on had one thing in common: &lt;strong&gt;the business didn't actually know its data model yet.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Assumption Behind Most API Design
&lt;/h2&gt;

&lt;p&gt;Most API examples start with a stable domain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a customer has a name.&lt;/li&gt;
&lt;li&gt;an order has a status.&lt;/li&gt;
&lt;li&gt;a product has a price.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From there, everything makes sense. You build entities and DTOs. Then attach validation rules, expose an API.&lt;/p&gt;

&lt;p&gt;The model exists first - the API is built around it. This works beautifully when the model is known. &lt;/p&gt;

&lt;p&gt;But many enterprise projects don't work like that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Reality I Kept Seeing
&lt;/h2&gt;

&lt;p&gt;For several years, I worked on APIs inside a large company. The company was not selling software, software was simply one of the tools supporting the real business. That changes everything. Because the business rarely arrives with a complete model. Instead, requirements often look like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;We need to accept these fields.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A few weeks later:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;We also need these additional fields.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A month later:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Another partner sends a slightly different structure.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And eventually:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Existing clients must continue working exactly as before.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;At that point, the beautiful DTO hierarchy starts fighting reality. Not because the code is bad. Because the assumptions were wrong. We assumed we already understood the shape of the domain.&lt;/p&gt;

&lt;p&gt;We didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Endless Refactoring Cycle
&lt;/h2&gt;

&lt;p&gt;The pattern became familiar.&lt;/p&gt;

&lt;p&gt;Week 1:&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;"customerId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"123"&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;100&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;Week 3:&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;"customerId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"123"&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;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"customerType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"business"&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;Week 6:&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;"customerId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"123"&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;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"customerType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"business"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"partnerSpecificData"&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="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;Week 10:&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;"customerId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"123"&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;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"legacyField"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"must still be accepted"&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;Every change triggered another round of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DTO updates&lt;/li&gt;
&lt;li&gt;Validator updates&lt;/li&gt;
&lt;li&gt;Serializer changes&lt;/li&gt;
&lt;li&gt;Documentation changes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code stayed clean, the model stayed elegant, the API contract remained unstable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Moment I Changed My Mind
&lt;/h2&gt;

&lt;p&gt;At some point I realized I was solving the wrong problem. I wasn't trying to model the business. I was trying to define what the API accepted. Those are not always the same thing. An API contract answers a much simpler question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What payload is valid today?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It doesn't need to predict the future, to represent the perfect domain model. It only needs to describe the contract between a client and a server. That realization pushed me toward JSON Schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why JSON Schema Felt Different
&lt;/h2&gt;

&lt;p&gt;JSON Schema starts from a completely different assumption. It doesn't care about entities, DTOs and about object hierarchies. It only describes data.&lt;/p&gt;

&lt;p&gt;For example:&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"required"&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="s2"&gt;"customerId"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"properties"&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;"customerId"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"number"&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;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;That's it. A contract - nothing more, nothing less. The schema says what is allowed. The application decides what to do with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Contract First, Model Later
&lt;/h2&gt;

&lt;p&gt;The biggest shift for me was psychological. I stopped pretending I already knew the final model. Instead, I accepted that the contract would evolve. The schema could evolve with it. When the business changed requirements, I updated the contract.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;not an entire object graph.&lt;/li&gt;
&lt;li&gt;not a DTO hierarchy.&lt;/li&gt;
&lt;li&gt;not a collection of serializer groups.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Just the contract&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Over time, stable patterns emerged. Only then did it make sense to extract real domain concepts.&lt;/p&gt;

&lt;h2&gt;
  
  
  This Is Why I Built My Symfony Bundle
&lt;/h2&gt;

&lt;p&gt;Eventually I wanted to use JSON Schema validation directly inside Symfony applications. Not as documentation or as generated code.&lt;/p&gt;

&lt;p&gt;As a runtime contract.&lt;/p&gt;

&lt;p&gt;I wanted to take a schema, validate incoming payloads, and know immediately whether the request matched the agreement between the client and the server. That idea eventually became the bundle I've been working on. But the bundle itself is not the important part. The important part is the lesson that led to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;I still use DTOs. I still use validation constraints. I still think they are excellent tools. But I no longer start with them.&lt;/p&gt;

&lt;p&gt;When requirements are unstable and the business is still discovering its own processes, I start with the contract. For me, that contract is often a JSON Schema.&lt;/p&gt;

&lt;p&gt;Not because it's more elegant - because it reflects reality better.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;p.s. and I strongly suspect that 95% of businesses in the world operate under this kind of entropy, and some of the readers here are either in it right now or have experienced it before. Tell me how you deal with it — how do you fight this entropy?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>php</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Multi-Scenario Docker Pattern: how to build a reproducible Docker environment for any conditions</title>
      <dc:creator>David Evdoshchenko</dc:creator>
      <pubDate>Thu, 04 Jun 2026 12:09:52 +0000</pubDate>
      <link>https://dev.to/outcomer/the-multi-scenario-docker-pattern-how-to-build-a-reproducible-docker-environment-for-any-conditions-aho</link>
      <guid>https://dev.to/outcomer/the-multi-scenario-docker-pattern-how-to-build-a-reproducible-docker-environment-for-any-conditions-aho</guid>
      <description>&lt;p&gt;We had CI passing while production kept breaking.&lt;/p&gt;

&lt;p&gt;The application ran inside a highly restricted government network, where accessing production sometimes required physical presence in a ministry office in another country.&lt;/p&gt;

&lt;p&gt;If something went wrong, debugging was rarely direct. In many cases it meant traveling on-site, passing security checks, and accessing internal workstations just to inspect logs or confirm behavior.&lt;/p&gt;

&lt;p&gt;When that wasn’t possible, we were forced into guesswork: comparing environments, reproducing issues locally, and assuming configuration drift.&lt;/p&gt;

&lt;p&gt;This is where we first encountered a structural problem: environment drift was no longer theoretical — it was operational reality.&lt;/p&gt;

&lt;p&gt;At some point, we asked for a full specification of the production runtime (PHP version, extensions, OS packages, configuration).&lt;/p&gt;

&lt;p&gt;We containerized the system and standardized delivery as a fully built Docker image matching production exactly.&lt;/p&gt;

&lt;p&gt;From that point on, the real problem became clear: keeping development, CI, and production environments from silently diverging.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Principle: one runtime, many scenarios
&lt;/h2&gt;

&lt;p&gt;This pattern is built on a simple idea:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;one runtime + multiple deployment scenarios&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The Dockerfile and the base environment must be singular. Differences are only allowed at the scenario level. When all scenarios share one runtime and one set of common configurations, divergence between environments becomes explicit rather than accidental.&lt;/p&gt;

&lt;p&gt;A scenario is not just a compose file. It is a self-contained unit for launching an environment, consisting of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;docker-compose.yml&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.env&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Makefile&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;devcontainer.json&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;additional scripts&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Project Structure
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my-app/
├── .devcontainer/
│   ├── _configs/              # shared runtime configurations
│   ├── _scripts/              # shared entrypoint scripts
│   ├── _data/                 # auxiliary binary dependencies
│   ├── scenario-mapped/       # local development (bind mount)
│   │   ├── docker-compose.yml
│   │   ├── devcontainer.json
│   │   ├── Makefile
│   │   └── .env
│   ├── scenario-embedded/     # deploy image (code inside the image)
│   │   ├── docker-compose.yml
│   │   ├── devcontainer.json
│   │   ├── Makefile
│   │   └── .env
│   ├── Dockerfile.app
│   ├── Dockerfile.database
│   └── Dockerfile.*
├── .env                       # base application configuration
└── ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Execution Flow
&lt;/h2&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.amazonaws.com%2Fuploads%2Farticles%2Fpjghd2l1e0qkctdeenmw.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.amazonaws.com%2Fuploads%2Farticles%2Fpjghd2l1e0qkctdeenmw.png" alt="Execution Flow" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Different scenarios share the same runtime and the same set of common resources. Scenarios do not create different systems — they only switch the way the same system is launched.&lt;/p&gt;




&lt;h2&gt;
  
  
  Working Example
&lt;/h2&gt;

&lt;p&gt;All files and scenarios shown in this article are available in a separate repository:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/outcomer/multi-scenario-docker-pattern" rel="noopener noreferrer"&gt;https://github.com/outcomer/multi-scenario-docker-pattern&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The repository contains a fully working example with the directory structure, Dockerfiles, docker-compose, Makefile, and Dev Container support.&lt;/p&gt;




&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  One base runtime
&lt;/h3&gt;

&lt;p&gt;All scenarios use the same set of Dockerfiles. For example, &lt;code&gt;Dockerfile.app&lt;/code&gt; might contain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PHP runtime;&lt;/li&gt;
&lt;li&gt;extensions;&lt;/li&gt;
&lt;li&gt;Composer;&lt;/li&gt;
&lt;li&gt;system dependencies;&lt;/li&gt;
&lt;li&gt;base web server configuration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This layer does not depend on any scenario.&lt;/p&gt;




&lt;h3&gt;
  
  
  Scenarios differ only in how they launch
&lt;/h3&gt;

&lt;p&gt;Scenarios define: how code gets into the container, which environment variables are used, which tools are enabled.&lt;/p&gt;

&lt;h4&gt;
  
  
  scenario-mapped
&lt;/h4&gt;

&lt;p&gt;Local development: code is mounted via bind mount, changes are reflected instantly, fast dev cycle, convenient for debugging.&lt;/p&gt;

&lt;h4&gt;
  
  
  scenario-embedded
&lt;/h4&gt;

&lt;p&gt;Production / CI:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;code is copied inside the image;&lt;/li&gt;
&lt;li&gt;the container does not depend on the host;&lt;/li&gt;
&lt;li&gt;the environment is reproducible anywhere.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why This Matters
&lt;/h2&gt;

&lt;p&gt;The key idea of the pattern:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;consistency is achieved not by team discipline, but by project structure&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;All scenarios use one runtime and one set of configurations from &lt;code&gt;_configs&lt;/code&gt;. This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;differences become explicit;&lt;/li&gt;
&lt;li&gt;runtime versions do not drift silently;&lt;/li&gt;
&lt;li&gt;changes are automatically applied to all scenarios.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Note: this does not make drift impossible, but it makes it visible and controllable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Not Docker Compose Profiles
&lt;/h2&gt;

&lt;p&gt;Docker Compose Profiles allow enabling and disabling services within a single compose file. For simple cases this is enough. However, profiles solve only one problem — managing the set of services.&lt;/p&gt;

&lt;p&gt;In this approach, a scenario is a broader concept. It includes not just a compose file, but also:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;.env&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Makefile&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;devcontainer.json&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;additional scripts;&lt;/li&gt;
&lt;li&gt;environment launch rules.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A scenario is a complete environment, not just a service toggle.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Separate Scenario Directories
&lt;/h2&gt;

&lt;p&gt;The typical path of project evolution often looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker-compose.yml
    ↓
docker-compose.dev.yml
    ↓
docker-compose.prod.yml
    ↓
docker-compose.staging.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Over time this becomes a system of overrides:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.yml &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.override.yml &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.staging.yml &lt;span class="se"&gt;\&lt;/span&gt;
  up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Understanding what will actually end up in the final configuration becomes increasingly difficult. In the scenario approach, launching looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; .devcontainer/scenario-mapped
make up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; .devcontainer/scenario-embedded
make deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A scenario becomes a physical object in the repository, not a combination of flags and files.&lt;/p&gt;




&lt;h2&gt;
  
  
  CI Is Just Another Scenario
&lt;/h2&gt;

&lt;p&gt;An interesting side effect of this approach is that CI stops being a separate world. Instead of special logic inside GitHub Actions or GitLab CI, the pipeline can use the same scenario as the rest of the environments. Local development, CI, and production start using the same launch model. The fewer differences between these environments, the less likely you are to get surprises after deployment.&lt;/p&gt;




&lt;h2&gt;
  
  
  Podman Support
&lt;/h2&gt;

&lt;p&gt;The pattern is not tied to Docker Engine. Since scenarios describe the way of launching rather than a specific container engine, the same set of scenarios can be used with both Docker and Podman through a compatible interface. Differences remain at the level of the launch environment, not the project architecture.&lt;/p&gt;




&lt;h2&gt;
  
  
  Practical Usage
&lt;/h2&gt;

&lt;p&gt;Local development:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; .devcontainer/scenario-mapped
make up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; .devcontainer/scenario-embedded
make deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Makefile Scenario Example
&lt;/h2&gt;

&lt;p&gt;A full Makefile typically contains dozens of commands. To understand the pattern, it is enough to see the core idea:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight make"&gt;&lt;code&gt;&lt;span class="nl"&gt;deploy&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="p"&gt;@$(&lt;/span&gt;MAKE&lt;span class="p"&gt;)&lt;/span&gt; build
    &lt;span class="p"&gt;@$(&lt;/span&gt;MAKE&lt;span class="p"&gt;)&lt;/span&gt; migrations-check
    &lt;span class="p"&gt;@$(&lt;/span&gt;MAKE&lt;span class="p"&gt;)&lt;/span&gt; up
    &lt;span class="p"&gt;@$(&lt;/span&gt;MAKE&lt;span class="p"&gt;)&lt;/span&gt; migrations-run
    &lt;span class="p"&gt;@$(&lt;/span&gt;MAKE&lt;span class="p"&gt;)&lt;/span&gt; healthcheck
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scenario encapsulates the knowledge of exactly how a launch or deployment should happen. The user only needs to call one command.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Pattern Guarantees
&lt;/h2&gt;

&lt;p&gt;The pattern provides:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a single runtime for all scenarios;&lt;/li&gt;
&lt;li&gt;no hidden differences between dev and prod;&lt;/li&gt;
&lt;li&gt;explicit separation of launch scenarios;&lt;/li&gt;
&lt;li&gt;isolation of dev tools from production;&lt;/li&gt;
&lt;li&gt;centralized configuration through a shared layer.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;p&gt;The pattern does not eliminate the need for architectural discipline. It is still important to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;not add scenario-specific logic to the shared runtime;&lt;/li&gt;
&lt;li&gt;not duplicate configurations outside &lt;code&gt;_configs&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;not mix dev and production behavior inside a Dockerfile.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Most problems in Docker projects do not appear because of containers — they appear because of multiple nearly identical ways of launching the same application. The Multi-Scenario Docker Pattern addresses exactly this problem: instead of several gradually diverging environments, you get one runtime and several explicit scenarios for using it.&lt;/p&gt;

&lt;p&gt;This pattern emerged from a real production constraint, not a theoretical design exercise.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;DON'T CHANGE THE ENVIRONMENT — CHANGE THE SCENARIO&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Working Example
&lt;/h2&gt;

&lt;p&gt;All files and scenarios shown in this article are available in a separate repository:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/outcomer/multi-scenario-docker-pattern" rel="noopener noreferrer"&gt;https://github.com/outcomer/multi-scenario-docker-pattern&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The repository contains a fully working example with the directory structure, Dockerfiles, docker-compose, Makefile, and Dev Container support.&lt;/p&gt;




&lt;p&gt;What’s your go-to pattern for keeping Docker behavior consistent across environments (e.g. dev, CI, deploy, others)? I’d love to hear what has actually worked for you in the comments.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>cicd</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I got tired of describing the same request 3 times in Symfony</title>
      <dc:creator>David Evdoshchenko</dc:creator>
      <pubDate>Fri, 29 May 2026 09:43:05 +0000</pubDate>
      <link>https://dev.to/outcomer/i-got-tired-of-describing-the-same-request-3-times-in-symfony-3jl2</link>
      <guid>https://dev.to/outcomer/i-got-tired-of-describing-the-same-request-3-times-in-symfony-3jl2</guid>
      <description>&lt;p&gt;When building Symfony APIs, I kept duplicating the same request contract:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;validation rules in a DTO&lt;/li&gt;
&lt;li&gt;OpenAPI schema (often separately)&lt;/li&gt;
&lt;li&gt;mapping / glue code around it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After enough endpoints, the API layer started feeling heavier than the business logic.&lt;/p&gt;

&lt;p&gt;Here’s a tiny example (validation in a 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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CreateUserRequest&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[Assert\NotBlank]&lt;/span&gt;
    &lt;span class="na"&gt;#[Assert\Email]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$email&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 &lt;strong&gt;same&lt;/strong&gt; &lt;code&gt;email&lt;/code&gt; then needs to exist again in OpenAPI docs - and the two drift over time.&lt;/p&gt;

&lt;p&gt;So I built a small Symfony bundle to reduce that duplication.&lt;/p&gt;

&lt;p&gt;Instead of describing the request multiple times, I keep a single JSON Schema:&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"required"&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="s2"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"properties"&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;"email"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"format"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email"&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;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;That schema becomes the source of truth for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;request validation&lt;/li&gt;
&lt;li&gt;request mapping&lt;/li&gt;
&lt;li&gt;OpenAPI generation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Basic usage:&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="na"&gt;#[MapRequest('schemas/user-create.json')]&lt;/span&gt;
&lt;span class="nc"&gt;UserCreateDto&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's not trying to replace Symfony (or API Platform). The goal is to adopt it gradually and write less boilerplate while keeping request contracts explicit.&lt;/p&gt;

&lt;p&gt;Project: &lt;a href="https://github.com/outcomer/symfony-json-schema-validation" rel="noopener noreferrer"&gt;https://github.com/outcomer/symfony-json-schema-validation&lt;/a&gt;&lt;br&gt;
Docs: &lt;a href="https://outcomer.github.io/symfony-json-schema-validation/" rel="noopener noreferrer"&gt;https://outcomer.github.io/symfony-json-schema-validation/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What part do you duplicate the most in Symfony APIs: validation, OpenAPI, or mapping?&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>api</category>
      <category>validation</category>
      <category>json</category>
    </item>
    <item>
      <title>I had to build my own Symfony validation bundle because no existing one fits my requirements</title>
      <dc:creator>David Evdoshchenko</dc:creator>
      <pubDate>Wed, 11 Mar 2026 22:02:22 +0000</pubDate>
      <link>https://dev.to/outcomer/i-had-to-build-my-own-symfony-validation-bundle-because-no-existing-one-fits-my-requirements-5abp</link>
      <guid>https://dev.to/outcomer/i-had-to-build-my-own-symfony-validation-bundle-because-no-existing-one-fits-my-requirements-5abp</guid>
      <description>&lt;h2&gt;
  
  
  Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Long story short&lt;/li&gt;
&lt;li&gt;The Problem&lt;/li&gt;
&lt;li&gt;The Idea&lt;/li&gt;
&lt;li&gt;The Solution&lt;/li&gt;
&lt;li&gt;
Quick Examples

&lt;ul&gt;
&lt;li&gt;Example 1&lt;/li&gt;
&lt;li&gt;Example 2&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;What's the result?&lt;/li&gt;
&lt;li&gt;The Full Story&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Long story short
&lt;/h2&gt;

&lt;p&gt;I created a bundle for request validation with JSON Schema because no existing "schema-first" validator fit my requirements. Now I just attach a JSON file to a route and get everything at once: validation, DTO mapping, and OpenAPI documentation from a single source of truth.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/outcomer/symfony-json-schema-validation" rel="noopener noreferrer"&gt;https://github.com/outcomer/symfony-json-schema-validation&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://outcomer.github.io/symfony-json-schema-validation/" rel="noopener noreferrer"&gt;https://outcomer.github.io/symfony-json-schema-validation/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Most validation solutions that can generate API documentation from code (in the Symfony world I mostly mean FOSRestBundle and API Platform) assume that your business logic is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Well defined and relatively stable&lt;/li&gt;
&lt;li&gt;Close to a classic CRUD model (or CRUD with small deviations)&lt;/li&gt;
&lt;li&gt;Exposed via clean, REST-style endpoints that you fully control&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In other words, they assume your application defines the contract and the outside world adapts to it.&lt;/p&gt;

&lt;p&gt;But in many real projects it is the opposite: the API contract is defined somewhere else (legacy frontend, external systems, partners), and you have to adapt to that contract. That is where problems start.&lt;/p&gt;

&lt;p&gt;Here is a simplified example. The API expects this payload:&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"company"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"user"&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"John"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"john@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"company"&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Acme"&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;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;With the following rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If &lt;code&gt;type = "company"&lt;/code&gt; then &lt;code&gt;user.company.name&lt;/code&gt; is required&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;type = "person"&lt;/code&gt; then &lt;code&gt;user.company&lt;/code&gt; must be absent&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Is this the most elegant API design? Probably not. But imagine a company with 2000 people and a frontend written years ago that sends exactly this structure. You cannot just redesign everything because you do not like the shape of the JSON.&lt;/p&gt;

&lt;p&gt;Now, what does the "ideal" Symfony validation setup suggest here?&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserDto&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[Assert\NotBlank]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="na"&gt;#[Assert\Valid]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;?CompanyDto&lt;/span&gt; &lt;span class="nv"&gt;$company&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;This does not express the conditional logic "company.name is required when type = company". To implement this you usually end up with one of the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Custom constraint with its own validator&lt;/li&gt;
&lt;li&gt;Manual validation logic in the controller or a service&lt;/li&gt;
&lt;li&gt;Custom normalizer / denormalizer with additional checks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then another question appears: how do you forbid extra properties that are not defined in &lt;code&gt;UserDto&lt;/code&gt;? For a long time you simply could not. In newer Symfony versions you can write something like:&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="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;MapRequestPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;serializationContext&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'allow_extra_attributes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;)]&lt;/span&gt;

&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;MapQueryString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;serializationContext&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'allow_extra_attributes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&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;Whether this works for query parameters depends on the exact types and context. For headers this approach does not work at all.&lt;/p&gt;

&lt;p&gt;You might ask: why be so strict? Why not just ignore extra parameters? Because in real life this often leads to subtle bugs. A typical scenario: you had a query parameter &lt;code&gt;offset&lt;/code&gt; and later renamed it to &lt;code&gt;page&lt;/code&gt; for consistency with other APIs. Some client code still sends &lt;code&gt;offset&lt;/code&gt;. If you ignore unknown parameters, the request "works", but returns the wrong page. You then spend time debugging something that could have been caught immediately.&lt;/p&gt;

&lt;p&gt;With strict validation the client would get a clear error about an unknown parameter, and the problem would be visible right away.&lt;/p&gt;

&lt;p&gt;My personal view is that even though an API has no visual UI, it still has UX. Clients should receive clear, precise error messages, not a generic "Provided data is incorrect". Detailed validation errors are also in the interest of the backend team: fewer support tickets, less time spent guessing what went wrong on the client side.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Idea
&lt;/h2&gt;

&lt;p&gt;The kind of validation I needed has actually existed for years, just not in the form of typical Symfony validators. I am talking about the JSON Schema standard:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://json-schema.org/specification" rel="noopener noreferrer"&gt;https://json-schema.org/specification&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;JSON Schema is a declarative language for defining structure and constraints for JSON data&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It is designed exactly for problems like the ones above (and much more complex ones):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Conditional rules based on other fields&lt;/li&gt;
&lt;li&gt;Nested, deeply structured data&lt;/li&gt;
&lt;li&gt;Strict control over allowed and forbidden properties&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the idea was simple: instead of forcing my API contract into DTO classes and annotations, let Symfony validate incoming requests against a JSON Schema that fully describes the contract.&lt;/p&gt;

&lt;p&gt;In other words, make Symfony request validation schema-first, with JSON Schema as the single source of truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution
&lt;/h2&gt;

&lt;p&gt;The good news was that I did not need to implement JSON Schema myself. There was already a solid PHP implementation:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://opis.io/json-schema/" rel="noopener noreferrer"&gt;https://opis.io/json-schema/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The library takes a valid JSON Schema and any input data, validates the data against the schema and either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;returns the data (when everything is valid), or&lt;/li&gt;
&lt;li&gt;returns a structured list of validation errors.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From there, the rest was mostly integration work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Make it convenient to plug this validation into a Symfony project&lt;/li&gt;
&lt;li&gt;Wire it into the request lifecycle&lt;/li&gt;
&lt;li&gt;Provide a way to map validated data into DTOs when needed&lt;/li&gt;
&lt;li&gt;Integrate with Nelmio so that OpenAPI documentation is generated from the same schemas&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Below I will show a couple of short examples. In the "The Full Story" section I describe how I removed duplication between validation attributes and documentation, and how the bundle ended up solving both validation and API specification generation from a single source of truth: the JSON Schema files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Examples
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Example 1&lt;/li&gt;
&lt;li&gt;Example 2&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The schema&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;"$schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://json-schema.org/draft-07/schema#"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"properties"&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;"query"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"properties"&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;"additionalProperties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;"headers"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"properties"&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;"authorization"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer token for authentication"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"pattern"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^Bearer .+"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"example"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ6..."&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;"x-api-version"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"API version"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"enum"&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="s2"&gt;"v1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"v2"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"example"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"v1"&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;"content-type"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Request content type"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"enum"&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="s2"&gt;"application/json"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"example"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"application/json"&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;span class="nl"&gt;"additionalProperties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;"body"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"properties"&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;"name"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"minLength"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"maxLength"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"User's full name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"example"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Jane Smith"&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;"email"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"format"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"User's email address"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"example"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"john.doe@example.com"&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;"age"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"integer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"minimum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;21&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"maximum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"User's age (optional)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"example"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&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;span class="nl"&gt;"required"&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="s2"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"additionalProperties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&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;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;h3&gt;
  
  
  Example 1: validation using the built-in request object
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;OA\Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;operationId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'validateUser'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Validate user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="na"&gt;#[Route('/user', name: '_example_validation_user', methods: ['POST'])]&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;validateUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;MapRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'./user-create.json'&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="nc"&gt;ValidatedRequest&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getPayload&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$body&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getBody&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&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="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'success'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'message'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'User data is valid'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'data'&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;'name'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'age'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'example'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'This uses ValidatedRequest (standard way)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;200&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;h3&gt;
  
  
  Example 2: validation using a custom DTO (via ValidatedDtoInterface)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;OA\Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;operationId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'createProfile'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Create profile'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="na"&gt;#[Route('/profile', name: '_example_validation_profile', methods: ['POST'])]&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;createProfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;MapRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'./user-create.json'&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="nc"&gt;UserApiDtoRequest&lt;/span&gt; &lt;span class="nv"&gt;$profile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&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="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'success'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'message'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Profile created successfully'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'profile'&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;'name'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$profile&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$profile&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'age'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$profile&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'note'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'This demonstrates DTO auto-injection: MapRequestResolver calls %s::fromPayload() automatically'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;UserApiDtoRequest&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="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;201&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;h2&gt;
  
  
  What's the result?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Focused solution: instead of reinventing the wheel, the bundle fills a specific gap that existing Symfony tools do not cover well (schema-first request validation).&lt;/li&gt;
&lt;li&gt;Less duplication: you no longer have to mirror the same rules in DTO constraints, controllers and OpenAPI annotations.&lt;/li&gt;
&lt;li&gt;Automatic sync: Nelmio builds documentation from the same JSON Schema files that are used for validation, so your docs always match the real behavior.&lt;/li&gt;
&lt;li&gt;Contract-centric design: the entire API contract lives in clean JSON files rather than being scattered across PHP attributes and PHP classes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you were looking for a way to validate requests without creating a large number of redundant DTO classes and annotations, this bundle is designed for that use case:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/outcomer/symfony-json-schema-validation" rel="noopener noreferrer"&gt;https://github.com/outcomer/symfony-json-schema-validation&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://outcomer.github.io/symfony-json-schema-validation/" rel="noopener noreferrer"&gt;https://outcomer.github.io/symfony-json-schema-validation/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Full Story
&lt;/h2&gt;

&lt;p&gt;This article is the short, focused version of the story: what the bundle does and how to start using it. If you want the long version with all the scars and details, I wrote a separate, much bigger post.&lt;/p&gt;

&lt;p&gt;In that post I go through:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;how the bundle was born in a very messy real project, not in a greenfield demo&lt;/li&gt;
&lt;li&gt;why classic DTO + Assertions validation did not survive 500+ routes&lt;/li&gt;
&lt;li&gt;how JSON Schema became the single contract language for backend, frontend and docs&lt;/li&gt;
&lt;li&gt;how the bundle glues Symfony, Opis JSON Schema and Nelmio together&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can read the full story here (starting from The Full Story section):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://outcomer.hashnode.dev/symfony-bundle-that-validates-anything-and-everything#the-full-story" rel="noopener noreferrer"&gt;https://outcomer.hashnode.dev/symfony-bundle-that-validates-anything-and-everything#the-full-story&lt;/a&gt;&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>symfony</category>
      <category>productivity</category>
      <category>api</category>
    </item>
    <item>
      <title>I built a Symfony bundle that validates anything and everything.</title>
      <dc:creator>David Evdoshchenko</dc:creator>
      <pubDate>Sun, 08 Mar 2026 13:49:08 +0000</pubDate>
      <link>https://dev.to/outcomer/i-built-a-symfony-bundle-that-validates-anything-and-everything-4nah</link>
      <guid>https://dev.to/outcomer/i-built-a-symfony-bundle-that-validates-anything-and-everything-4nah</guid>
      <description>&lt;h2&gt;
  
  
  Long story short
&lt;/h2&gt;

&lt;p&gt;I created a bundle for request validation via JSON Schema because I was fed up with the lack of a simple "schema-first" validator. Now, I just attach a JSON file to a route, and everything - validation, DTO mapping, and OpenAPI documentation - works out of the box.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/outcomer/symfony-json-schema-validation" rel="noopener noreferrer"&gt;https://github.com/outcomer/symfony-json-schema-validation&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://outcomer.github.io/symfony-json-schema-validation/" rel="noopener noreferrer"&gt;https://outcomer.github.io/symfony-json-schema-validation/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Feel free to ask me whatever relevant to topic via comments here;&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pain Point
&lt;/h2&gt;

&lt;p&gt;I didn't set out to build my own bundle. I had a simple task: validate an incoming request against specific conditions.&lt;br&gt;
Sounds basic, right? But when I looked for a solution, I found that the modern Symfony world is stuck in one specific pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;create a DTOs;&lt;/li&gt;
&lt;li&gt;write a ton of assertions (Annotations/Attributes);&lt;/li&gt;
&lt;li&gt;if the structure is complex or dynamic-suffer and write custom normalizers;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I searched everywhere but couldn't find a straightforward way to say: "Here is my JSON, here is my schema, just check them and give me an object." Instead, I was forced to create mountains of boilerplate that I didn't need.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why DTO + Assertions isn't always the answer
&lt;/h3&gt;

&lt;p&gt;The classic approach with assertions in PHP classes works fine for simple forms or strictly structured data. But as soon as you deal with deep nesting or requirements that don't play well with normalization - it's much easier to define the requirements once in the JSON Schema standard. Otherwise, you end up duplicating logic - firstly in code, then in docs.&lt;/p&gt;

&lt;p&gt;I wanted flexibility. I wanted to use a standard that everyone understands - from frontend developers to QA engineers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Solution
&lt;/h2&gt;

&lt;p&gt;How I solved it: I built a bundle that makes JSON Schema a "first-class citizen" in Symfony. Now, instead of describing validation rules in PHP code, I simply point to a schema.&lt;/p&gt;

&lt;p&gt;The bundle allows you to validate data either into a built-in DTO (which always contains validated path, query, headers, and body) or into your own custom class, provided it implements the ValidatedDtoInterface.&lt;/p&gt;

&lt;h3&gt;
  
  
  Example 1: Validation into the built-in ValidatedRequest
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;OA\Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;operationId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'validateUser'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Validate user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="na"&gt;#[Route('/user', name: '_example_validation_user', methods: ['POST'])]&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;validateUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;MapRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'./user-create.json'&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="nc"&gt;ValidatedRequest&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getPayload&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$body&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getBody&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&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="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'success'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'message'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'User data is valid'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'data'&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;'name'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'age'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'example'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'This uses ValidatedRequest (standard way)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;200&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;h3&gt;
  
  
  Example 2: Validation into your own DTO (via ValidatedDtoInterface)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;OA\Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;operationId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'createProfile'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Create profile'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="na"&gt;#[Route('/profile', name: '_example_validation_profile', methods: ['POST'])]&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;createProfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;MapRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'./user-create.json'&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="nc"&gt;UserApiDtoRequest&lt;/span&gt; &lt;span class="nv"&gt;$profile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&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="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'success'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'message'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Profile created successfully'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'profile'&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;'name'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$profile&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$profile&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'age'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$profile&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'note'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'This demonstrates DTO auto-injection: MapRequestResolver calls %1$s::fromPayload() automatically'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;UserApiDtoRequest&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="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;201&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;h2&gt;
  
  
  What's the result?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;I didn't "reinvent the wheel"; I simply filled a gap in the ecosystem;&lt;/li&gt;
&lt;li&gt;No more duplicate work: No more writing endless &lt;code&gt;#[Assert\NotBlank]&lt;/code&gt;, &lt;code&gt;#[Assert\Type("string")]&lt;/code&gt;, etc;&lt;/li&gt;
&lt;li&gt;Automatic Sync: Nelmio picks up the schema from the same file - your documentation never lies;
&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.amazonaws.com%2Fuploads%2Farticles%2Fylrk9zmy6mrnnxrffl03.png" alt="API Docs page" width="800" height="430"&gt;
&lt;/li&gt;
&lt;li&gt;Contract-Centric: The entire API contract lives in clean JSON files rather than being scattered across PHP attributes;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you, like me, were looking for a way to validate requests like a human being without creating dozens of redundant classes - here is the solution:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/outcomer/symfony-json-schema-validation" rel="noopener noreferrer"&gt;https://github.com/outcomer/symfony-json-schema-validation&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://outcomer.github.io/symfony-json-schema-validation/" rel="noopener noreferrer"&gt;https://outcomer.github.io/symfony-json-schema-validation/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>opensource</category>
      <category>productivity</category>
      <category>symfony</category>
      <category>api</category>
    </item>
    <item>
      <title>I had to build my own Symfony validation bundle because no existing one fits my requirements.</title>
      <dc:creator>David Evdoshchenko</dc:creator>
      <pubDate>Thu, 05 Mar 2026 19:53:54 +0000</pubDate>
      <link>https://dev.to/outcomer/stop-over-engineering-your-symfony-apis-a-pragmatic-json-schema-first-approach-to-validation-and-143p</link>
      <guid>https://dev.to/outcomer/stop-over-engineering-your-symfony-apis-a-pragmatic-json-schema-first-approach-to-validation-and-143p</guid>
      <description>&lt;p&gt;UPDATE: I have completely rewritten this guide to focus on a more pragmatic approach.&lt;/p&gt;

&lt;p&gt;My original article discussed the challenges of Symfony/OpenAPI validation, but since then, I've built a dedicated tool that solves this by using JSON Schema as the single source of truth.&lt;/p&gt;

&lt;p&gt;You can find the improved, "no-boilerplate" version of this article here: &lt;a href="https://dev.to/outcomer/i-built-a-symfony-bundle-that-validates-anything-and-everything-4nah"&gt;https://dev.to/outcomer/i-built-a-symfony-bundle-that-validates-anything-and-everything-4nah&lt;/a&gt;&lt;/p&gt;

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