<?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>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>
