<?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: SymfonyCasts</title>
    <description>The latest articles on DEV Community by SymfonyCasts (symfonycasts).</description>
    <link>https://dev.to/symfonycasts</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%2Forganization%2Fprofile_image%2F13806%2Fc3ff2e4e-1a98-4f00-b9cc-bbfd351a993c.png</url>
      <title>DEV Community: SymfonyCasts</title>
      <link>https://dev.to/symfonycasts</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/symfonycasts"/>
    <language>en</language>
    <item>
      <title>Playwright-PHP Changes the Game for Symfony Testing</title>
      <dc:creator>Leanna Pelham</dc:creator>
      <pubDate>Tue, 29 Sep 2026 16:53:16 +0000</pubDate>
      <link>https://dev.to/symfonycasts/playwright-php-changes-the-game-for-symfony-testing-p5e</link>
      <guid>https://dev.to/symfonycasts/playwright-php-changes-the-game-for-symfony-testing-p5e</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd3rq5mdysfp73kxhw05k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd3rq5mdysfp73kxhw05k.png" alt="Playwrite PHP Blog Post Image" width="768" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"We migrated our browser test suite from Panther to Playwright...&lt;/p&gt;

&lt;p&gt;Went from ~39 seconds to ~18 seconds. And now it's possible to run this suite with paratest...since we can parallelize it now!"&lt;/p&gt;

&lt;p&gt;&lt;a href="https://x.com/jwage/status/2091699483553743034" rel="noopener noreferrer"&gt;Jon Wage&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A test suite that relies on JavaScript, cut from ~39 seconds to ~18 seconds -&lt;br&gt;
&lt;strong&gt;~50% faster?!&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And now it can run in parallel with ParaTest?!&lt;/p&gt;

&lt;p&gt;I've been working with &lt;a href="https://github.com/smnandre" rel="noopener noreferrer"&gt;Simon André&lt;/a&gt; on this new Playwright + Symfony setup, and the whole thing is kind of blowing my mind.&lt;/p&gt;

&lt;p&gt;The architecture is incredibly clever and unlocks some things I didn't think were possible with end-to-end tests.&lt;/p&gt;

&lt;p&gt;Let's start at the bottom.&lt;/p&gt;
&lt;h2&gt;
  
  
  Playwright
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://playwright.dev/" rel="noopener noreferrer"&gt;Playwright&lt;/a&gt; is a browser automation tool from&lt;br&gt;
Microsoft.&lt;/p&gt;

&lt;p&gt;It controls real Chromium, Firefox, and WebKit browsers, so your tests can click around, fill out forms, execute JavaScript and interact with your app like a real user.&lt;/p&gt;

&lt;p&gt;The idea isn't new. We've had Selenium/WebDriver-style end-to-end testing for years.&lt;/p&gt;

&lt;p&gt;The problem? It wasn't a great experience. It was slow, flaky, and generally painful to work with.&lt;/p&gt;

&lt;p&gt;Playwright is a much more modern take on browser automation: fast, powerful, and built around today's web apps.&lt;/p&gt;

&lt;p&gt;The official library is JavaScript/TypeScript.&lt;/p&gt;

&lt;p&gt;But we're PHP developers. So...&lt;/p&gt;
&lt;h2&gt;
  
  
  Playwright-PHP
&lt;/h2&gt;

&lt;p&gt;That's where &lt;a href="https://github.com/playwright-php/playwright" rel="noopener noreferrer"&gt;&lt;code&gt;playwright-php/playwright&lt;/code&gt;&lt;/a&gt; comes in.&lt;/p&gt;

&lt;p&gt;Created by Simon along with other amazing contributors, its job - for our&lt;br&gt;
purposes - is basically to get Playwright installed and usable for PHP&lt;br&gt;
developers.&lt;/p&gt;

&lt;p&gt;It gives us a PHP API for controlling Playwright and writing end-to-end tests from PHP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Awesome!&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Playwright + Symfony 🤯
&lt;/h2&gt;

&lt;p&gt;But this is where things get &lt;strong&gt;really&lt;/strong&gt; interesting.&lt;/p&gt;

&lt;p&gt;Because Simon didn't stop at "Playwright, but for PHP".&lt;/p&gt;

&lt;p&gt;With &lt;a href="https://github.com/playwright-php/playwright-symfony" rel="noopener noreferrer"&gt;&lt;code&gt;playwright-php/playwright-symfony&lt;/code&gt;&lt;/a&gt;, he asked:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if we could make Playwright work &lt;em&gt;with&lt;/em&gt; Symfony's kernel?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;With traditional end-to-end testing - including tools like &lt;a href="https://github.com/symfony/panther" rel="noopener noreferrer"&gt;Symfony&lt;br&gt;
Panther&lt;/a&gt; - the architecture looks like you'd expect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser -&amp;gt; Web Server -&amp;gt; Symfony Kernel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser is a separate process, your app is behind a web server, and they talk over HTTP.&lt;/p&gt;

&lt;p&gt;Totally normal.&lt;/p&gt;

&lt;p&gt;But Playwright has a powerful trick up its sleeve: it can &lt;strong&gt;intercept network requests and provide the response itself&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;playwright-symfony&lt;/code&gt; takes that idea somewhere I absolutely did not expect.&lt;/p&gt;

&lt;h3&gt;
  
  
  From Browser Request to Symfony Request
&lt;/h3&gt;

&lt;p&gt;When the browser requests a page from your app, the request is intercepted&lt;br&gt;
&lt;strong&gt;before it ever reaches a web server&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It's converted into a Symfony &lt;code&gt;Request&lt;/code&gt;, passed &lt;strong&gt;directly to the Symfony kernel running inside your test&lt;/strong&gt;, and the resulting Symfony &lt;code&gt;Response&lt;/code&gt; is handed straight back to the browser.&lt;/p&gt;

&lt;p&gt;So this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser -&amp;gt; Web Server -&amp;gt; Symfony Kernel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;becomes this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser -&amp;gt; Symfony Kernel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;THE WEB SERVER IS A LIE!!!&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;🤯🤯🤯&lt;/p&gt;

&lt;p&gt;And this isn't a fake browser or some DOM simulation.&lt;/p&gt;

&lt;p&gt;It's still a &lt;strong&gt;real Chromium, Firefox or WebKit browser&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It's rendering the page.&lt;/p&gt;

&lt;p&gt;It's executing the JavaScript.&lt;/p&gt;

&lt;p&gt;Fetch requests, Turbo, Stimulus, whatever your frontend is doing - it's&lt;br&gt;
happening in a real browser.&lt;/p&gt;

&lt;p&gt;But the Symfony requests are being handled &lt;strong&gt;in-process by your kernel&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That combination is incredible:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real browser + real JavaScript + in-process Symfony kernel.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And suddenly the boundary that normally separates an E2E test from your Symfony test environment basically disappears. Your test and your application share the same process, so:&lt;/p&gt;

&lt;p&gt;The container is right there.&lt;/p&gt;

&lt;p&gt;The profiler is right there.&lt;/p&gt;

&lt;p&gt;Your mocked services are right there.&lt;/p&gt;

&lt;p&gt;This feels less like "another browser testing tool" and more like a new kind of Symfony test: the browser realism of an end-to-end test with the superpowers we normally only get from kernel testing.&lt;/p&gt;

&lt;p&gt;That is just &lt;strong&gt;so cool&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Zenstruck Browser
&lt;/h2&gt;

&lt;p&gt;And... this is where I got involved.&lt;/p&gt;

&lt;p&gt;Once I understood what was happening here, it felt &lt;strong&gt;game changing&lt;/strong&gt; for Symfony end-to-end testing. I wanted to push it further and get it into the hands of more people.&lt;/p&gt;

&lt;p&gt;So I brought Playwright support to &lt;a href="https://github.com/zenstruck/browser" rel="noopener noreferrer"&gt;&lt;code&gt;zenstruck/browser&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Zenstruck Browser is an expressive testing layer for Symfony apps. It gives you a fluent API for clicking links, filling forms, making assertions,&lt;br&gt;
authenticating users, accessing the container, working with the profiler and a bunch more.&lt;/p&gt;

&lt;p&gt;For years, when you needed a &lt;strong&gt;browser that actually runs your JavaScript&lt;/strong&gt;, we supported &lt;a href="https://github.com/symfony/panther" rel="noopener noreferrer"&gt;Symfony Panther&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Panther has been awesome for us. It brought end-to-end testing into the Symfony ecosystem and made something that was traditionally pretty painful much easier to use.&lt;/p&gt;

&lt;p&gt;But with all this new Playwright + Symfony magic available, I made a pretty big change:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I added Playwright support... and deprecated Panther.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And the really exciting part isn't just that Zenstruck Browser can now drive Playwright. It's how much of the experience from our normal kernel tests we can bring along with it.&lt;/p&gt;

&lt;p&gt;Want to authenticate a user without visiting the login page?&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;playwrightBrowser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;actingAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;visit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/account'&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;That logs the user in &lt;strong&gt;directly through Symfony's session&lt;/strong&gt;, while the real browser gets the resulting authentication cookie.&lt;/p&gt;

&lt;p&gt;Want to test redirects? You can stop on them, assert exactly where they're&lt;br&gt;
going, then follow them:&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;playwrightBrowser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;interceptRedirects&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;visit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/account'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertRedirectedTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/login'&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;And exceptions are especially cool.&lt;/p&gt;

&lt;p&gt;Normally, once a real browser and web server are involved, an exception happens on the other side of that boundary. But because Playwright's requests are running directly through our kernel, Zenstruck Browser can actually &lt;strong&gt;catch the real PHP exception&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;playwrightBrowser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;expectException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\DomainException&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;visit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/boom'&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;Not "the page returned a 500".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The actual exception!&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;DomainException: You cannot purchase this product.&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We also get container access, profiler support, shared assertions, cookie&lt;br&gt;
handling and a bunch of the other conveniences that make kernel tests so nice to work with.&lt;/p&gt;

&lt;p&gt;So instead of choosing between:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"nice Symfony functional test"&lt;/strong&gt; and &lt;strong&gt;"real browser with JavaScript"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;...we can get remarkably close to having both.&lt;/p&gt;

&lt;p&gt;And that's what made me want to push this forward.&lt;/p&gt;
&lt;h3&gt;
  
  
  Wait... DAMA Works?!
&lt;/h3&gt;

&lt;p&gt;My favorite part? What this means for your database.&lt;/p&gt;

&lt;p&gt;If you write Doctrine tests, you might use &lt;a href="https://github.com/dmaicher/doctrine-test-bundle" rel="noopener noreferrer"&gt;DAMADoctrineTestBundle&lt;/a&gt;. It wraps each test in a transaction and rolls it back when the test finishes. Your database is clean again instantly, and you never pay to rebuild it.&lt;/p&gt;

&lt;p&gt;It's fantastic. But with end-to-end tests, &lt;strong&gt;it never worked&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It couldn't. Your test opens a transaction on its connection, but the browser talks to a web server in a &lt;em&gt;different process&lt;/em&gt;, holding a &lt;em&gt;different&lt;/em&gt; connection. That process can't see your uncommitted data. So the transaction trick was off the table and you were back to purging tables or reloading fixtures between every single test.&lt;/p&gt;

&lt;p&gt;But now the request is handled &lt;strong&gt;inside your test process, on the same&lt;br&gt;
connection&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So DAMA just... works.&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="c1"&gt;// persisted in your test - never committed&lt;/span&gt;
&lt;span class="nc"&gt;ProductFactory&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;createOne&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="s1"&gt;'Widget'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;playwrightBrowser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;visit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/products'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSeeIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'#products'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Widget'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// the browser sees it!&lt;/span&gt;
&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real browser renders that product. Your JavaScript can fetch it. And when the test ends, it all disappears - nothing ever hits the database.&lt;/p&gt;

&lt;p&gt;It works in the other direction too: if the &lt;em&gt;request&lt;/em&gt; writes something, your test sees it afterwards, and it's still rolled back at the end.&lt;/p&gt;

&lt;p&gt;So we've dropped the web server, and now we've dropped the&lt;br&gt;
rebuild-the-database-every-test tax with it. And we haven't even parallelized anything yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  And... ParaTest
&lt;/h3&gt;

&lt;p&gt;Remember the other part of Jon's quote?&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"And now it's possible to run this suite with paratest... since we can parallelize it now!"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Because the Symfony app is running inside the test process instead of behind a shared web server, these Playwright tests can play nicely with&lt;br&gt;
&lt;a href="https://github.com/paratestphp/paratest" rel="noopener noreferrer"&gt;ParaTest&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So now we're not just talking about fast Playwright tests.&lt;/p&gt;

&lt;p&gt;We're talking about &lt;strong&gt;a Playwright suite you can actually run in parallel&lt;/strong&gt; - without giving up any of the above.&lt;/p&gt;

&lt;p&gt;That's how a Playwright suite goes from something you're nervous to grow into something you can actually keep adding to.&lt;/p&gt;

&lt;h2&gt;
  
  
  This Changes the Equation
&lt;/h2&gt;

&lt;p&gt;Browser tests have always come with trade-offs. You want a browser that actually runs your JavaScript? Great. But now you're dealing with a web server, separate processes, slower tests and less access to Symfony itself.&lt;/p&gt;

&lt;p&gt;This new Playwright + Symfony setup changes that equation. For the most part, you stop choosing.&lt;/p&gt;

&lt;p&gt;And, as Jon Wage showed us right at the beginning:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"...Went from ~39 seconds to ~18 seconds..."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's not a small improvement.&lt;/p&gt;

&lt;p&gt;I think Simon and the contributors to Playwright-PHP are building something&lt;br&gt;
genuinely special here, and I'm incredibly proud to be part of it and excited to see where it goes next.&lt;/p&gt;

&lt;p&gt;If you're testing Symfony apps in a browser, &lt;strong&gt;you should absolutely be paying attention to this project&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And we're building a full course on all of this:&lt;br&gt;
&lt;a href="https://symfonycasts.com/screencast/e2e-testing" rel="noopener noreferrer"&gt;Symfony End-to-End Testing: WebTestCase to Playwright&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's not out yet - head over there to read more and get notified the moment it's released.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>php</category>
      <category>symfony</category>
      <category>testing</category>
    </item>
    <item>
      <title>Keeping Doctrine Entities Honest with DTOs and ObjectMapper</title>
      <dc:creator>Leanna Pelham</dc:creator>
      <pubDate>Thu, 25 Jun 2026 18:27:13 +0000</pubDate>
      <link>https://dev.to/symfonycasts/keeping-doctrine-entities-honest-with-dtos-and-objectmapper-37ll</link>
      <guid>https://dev.to/symfonycasts/keeping-doctrine-entities-honest-with-dtos-and-objectmapper-37ll</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg13dvgruy7bu8ub9hvki.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg13dvgruy7bu8ub9hvki.png" alt="Blog Image of Cards" width="768" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Originally posted on &lt;a href="https://symfonycasts.com/blog/honest-entities" rel="noopener noreferrer"&gt;https://symfonycasts.com/blog/honest-entities&lt;/a&gt;&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;Your Doctrine entities are lying to you!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For years, the standard way to build Doctrine entities in Symfony has looked&lt;br&gt;
something like this (and it's still what MakerBundle generates today):&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;#[ORM\Entity]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConferenceTalk&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[ORM\Id]&lt;/span&gt;
    &lt;span class="na"&gt;#[ORM\GeneratedValue]&lt;/span&gt;
    &lt;span class="na"&gt;#[ORM\Column]&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;?int&lt;/span&gt; &lt;span class="nv"&gt;$id&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="na"&gt;#[Assert\NotBlank]&lt;/span&gt;
    &lt;span class="na"&gt;#[ORM\Column(length: 255)]&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$title&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="na"&gt;#[ORM\Column(type: Types::TEXT, nullable: true)]&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$abstract&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getId&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;?int&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="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getTitle&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;?string&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="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;setTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;static&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$title&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="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getAbstract&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;?string&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="k"&gt;abstract&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;setAbstract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$abstract&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;static&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;abstract&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$abstract&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="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At first glance, this looks perfectly reasonable.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;title&lt;/code&gt; field is required. We know that because it has a&lt;br&gt;
&lt;code&gt;NotBlank&lt;/code&gt; constraint and the database column is not nullable.&lt;/p&gt;

&lt;p&gt;But look closer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$title&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;setTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;static&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;getTitle&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;According to the PHP type system, the title is optional. In fact, the public&lt;br&gt;
API of this class explicitly allows us to set it to &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That means this is perfectly valid:&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="nv"&gt;$talk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ConferenceTalk&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And so is this:&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="nv"&gt;$talk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ConferenceTalk&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$talk&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both objects represent a conference talk that can never be successfully&lt;br&gt;
persisted.&lt;/p&gt;

&lt;p&gt;Eventually, Doctrine catches the problem:&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="nv"&gt;$talk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ConferenceTalk&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nv"&gt;$entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$talk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// boom!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;SQLSTATE[23000]: Integrity constraint violation: 1048 Column 'title' cannot be null&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The database knows that a conference talk must have a title.&lt;/p&gt;

&lt;p&gt;Our PHP code does not.&lt;/p&gt;

&lt;p&gt;So why do we build entities this way?&lt;/p&gt;

&lt;p&gt;Historically, this pattern was optimized for simplicity.&lt;/p&gt;

&lt;p&gt;A single object could be used everywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Render a form&lt;/li&gt;
&lt;li&gt;Receive submitted data&lt;/li&gt;
&lt;li&gt;Validate that data&lt;/li&gt;
&lt;li&gt;Persist it to the database&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For beginners, this is fantastic. There is very little ceremony, very little&lt;br&gt;
boilerplate, and very little to learn before you can build working CRUD&lt;br&gt;
screens.&lt;/p&gt;

&lt;p&gt;The tradeoff is that entities end up serving two very different purposes.&lt;/p&gt;

&lt;p&gt;They represent persisted application data, but they also act as form models.&lt;br&gt;
Because user input is often incomplete or invalid, entities need to be able&lt;br&gt;
to temporarily hold data that should never actually exist in the database.&lt;/p&gt;

&lt;p&gt;That's why we end up with a seemingly contradictory combination:&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;#[Assert\NotBlank]&lt;/span&gt;
&lt;span class="na"&gt;#[ORM\Column(nullable: false)]&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$title&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The validator says the field is &lt;em&gt;required&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The database mapping says it's &lt;em&gt;required&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The PHP type says it's &lt;em&gt;optional&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Those statements don't all agree.&lt;/p&gt;

&lt;p&gt;But what if our entities didn't need to act as form models at all?&lt;/p&gt;

&lt;p&gt;What if incomplete user input lived somewhere else?&lt;/p&gt;

&lt;h2&gt;
  
  
  Giving Incomplete Data Somewhere Else to Live
&lt;/h2&gt;

&lt;p&gt;Validation is not the problem.&lt;/p&gt;

&lt;p&gt;The problem is that we're asking our entity to be two different things at the&lt;br&gt;
same time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A valid &lt;code&gt;ConferenceTalk&lt;/code&gt; entity&lt;/li&gt;
&lt;li&gt;A form data model (a temporary container for incomplete user input)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So let's split those responsibilities.&lt;/p&gt;

&lt;p&gt;First, create a DTO (Data Transfer Object) that represents the form data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConferenceTalkDto&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;$title&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="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$abstract&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unlike our entity, the DTO is allowed to be incomplete.&lt;/p&gt;

&lt;p&gt;That's its job. It represents data coming from the outside world before we've&lt;br&gt;
proven that it's valid.&lt;/p&gt;

&lt;p&gt;Our entity can finally express its real requirements:&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;#[ORM\Entity]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConferenceTalk&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[ORM\Id]&lt;/span&gt;
    &lt;span class="na"&gt;#[ORM\GeneratedValue]&lt;/span&gt;
    &lt;span class="na"&gt;#[ORM\Column]&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;?int&lt;/span&gt; &lt;span class="nv"&gt;$id&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="na"&gt;#[ORM\Column(length: 255)]&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="na"&gt;#[ORM\Column(type: Types::TEXT, nullable: true)]&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$abstract&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getTitle&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&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="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getAbstract&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;?string&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="k"&gt;abstract&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;setAbstract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$abstract&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;abstract&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$abstract&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what's changed.&lt;/p&gt;

&lt;p&gt;The required &lt;code&gt;title&lt;/code&gt; is no longer nullable and must be provided when the&lt;br&gt;
entity is created:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$title&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 optional &lt;code&gt;abstract&lt;/code&gt; remains nullable, and does not have a constructor argument,&lt;br&gt;
because it truly is optional.&lt;/p&gt;

&lt;p&gt;Now the entity's API matches its actual requirements. If you want a&lt;br&gt;
&lt;code&gt;ConferenceTalk&lt;/code&gt;, you must provide a title: ""&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="nv"&gt;$talk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ConferenceTalk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Keeping Doctrine Entities Honest'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At this point, the PHP type system, the entity, and the database all agree on&lt;br&gt;
what is required and what is optional.&lt;/p&gt;

&lt;p&gt;We've eliminated an entire category of impossible states.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;ConferenceTalk&lt;/code&gt; can no longer exist without a title.&lt;/p&gt;

&lt;p&gt;The natural objection is: &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Great, but now I have two classes. Do I need to manually map data between them?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Historically, yes - and that was a legitimate objection.&lt;/p&gt;

&lt;p&gt;And that boilerplate is one of the reasons many Symfony applications have&lt;br&gt;
continued using entities directly with forms and validation.&lt;/p&gt;

&lt;p&gt;This is where &lt;a href="https://symfony.com/doc/current/object_mapper.html" rel="noopener noreferrer"&gt;Symfony's ObjectMapper component&lt;/a&gt;&lt;br&gt;
(&lt;a href="https://symfony.com/blog/new-in-symfony-7-3-objectmapper-component" rel="noopener noreferrer"&gt;added in 7.3&lt;/a&gt;) changes the tradeoff.&lt;/p&gt;

&lt;p&gt;Instead of manually creating entities from DTOs:&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="nv"&gt;$talk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ConferenceTalk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$dto&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$talk&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setAbstract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$dto&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;abstract&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;we can let &lt;code&gt;ObjectMapper&lt;/code&gt; do the transformation for us:&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="nv"&gt;$talk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$objectMapper&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$dto&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ConferenceTalk&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;One nice detail is that this remains fully type-safe. Thanks to generics,&lt;br&gt;
IDE autocompletion and static analysis tools can correctly infer that&lt;br&gt;
&lt;code&gt;$talk&lt;/code&gt; is a &lt;code&gt;ConferenceTalk&lt;/code&gt; instance.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The DTO remains flexible enough to receive and validate user input. The&lt;br&gt;
entity remains strict enough to represent valid application data.&lt;br&gt;
&lt;code&gt;ObjectMapper&lt;/code&gt; bridges the gap between the two.&lt;/p&gt;

&lt;p&gt;In other words:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The DTO receives and &lt;em&gt;validates&lt;/em&gt; user input&lt;/li&gt;
&lt;li&gt;The entity represents &lt;em&gt;valid&lt;/em&gt; application data&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ObjectMapper&lt;/code&gt; moves data between them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each object has a single responsibility, and each object tells the truth&lt;br&gt;
about what it represents.&lt;/p&gt;
&lt;h2&gt;
  
  
  What Does This Look Like in Practice?
&lt;/h2&gt;

&lt;p&gt;The nice thing about this approach is that it doesn't require a massive&lt;br&gt;
rewrite. Starting from a standard &lt;code&gt;make:crud&lt;/code&gt; setup, only five things change:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add a DTO&lt;/li&gt;
&lt;li&gt;Update the form to map to the DTO instead of the entity&lt;/li&gt;
&lt;li&gt;Make the entity requirements honest&lt;/li&gt;
&lt;li&gt;Update the create controller&lt;/li&gt;
&lt;li&gt;Update the edit controller&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;
  
  
  1. Add a DTO
&lt;/h3&gt;

&lt;p&gt;First, I created a new &lt;code&gt;ConferenceTalkDto&lt;/code&gt; class:&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="c1"&gt;// src/Dto/ConferenceTalkDto.php&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConferenceTalkDto&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;$title&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="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$abstract&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This class is now responsible for receiving user input and holding invalid&lt;br&gt;
data during validation.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Update the Form
&lt;/h3&gt;

&lt;p&gt;The form barely changes.&lt;/p&gt;

&lt;p&gt;Before:&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="c1"&gt;// src/Form/ConferenceTalkType.php&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;configureOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;OptionsResolver&lt;/span&gt; &lt;span class="nv"&gt;$resolver&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$resolver&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setDefaults&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'data_class'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;ConferenceTalk&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After:&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="c1"&gt;// src/Form/ConferenceTalkType.php&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;configureOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;OptionsResolver&lt;/span&gt; &lt;span class="nv"&gt;$resolver&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$resolver&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setDefaults&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'data_class'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;ConferenceTalkDto&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it.&lt;/p&gt;

&lt;p&gt;The form now hydrates the DTO instead of the entity.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Make the Entity Honest
&lt;/h3&gt;

&lt;p&gt;With validation moved to the DTO, the entity can finally express what is&lt;br&gt;
actually required.&lt;/p&gt;

&lt;p&gt;Before:&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="c1"&gt;// src/Entity/ConferenceTalk.php&lt;/span&gt;

&lt;span class="na"&gt;#[Assert\NotBlank]&lt;/span&gt;
&lt;span class="na"&gt;#[ORM\Column(length: 255)]&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$title&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="c1"&gt;// ...&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;setTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="c1"&gt;// (nullable property)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getTitle&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="c1"&gt;// (nullable return type)&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="n"&gt;title&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;After:&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="c1"&gt;// src/Entity/ConferenceTalk.php&lt;/span&gt;

&lt;span class="na"&gt;#[ORM\Column(length: 255)]&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;setTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="c1"&gt;// (non-nullable property)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getTitle&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="c1"&gt;// (non-nullable return type)&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="n"&gt;title&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 title is no longer nullable because a conference talk without a title&lt;br&gt;
should never exist.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Update the Create Controller
&lt;/h3&gt;

&lt;p&gt;This is where ObjectMapper comes in.&lt;/p&gt;

&lt;p&gt;Before, the form populated the entity directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/Controller/ConferenceTalkController.php&lt;/span&gt;

&lt;span class="nv"&gt;$talk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ConferenceTalk&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nv"&gt;$form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;createForm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;ConferenceTalkType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;$talk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$form&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;handleRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$form&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isSubmitted&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$form&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isValid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$talk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the form populates the 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="c1"&gt;// src/Controller/ConferenceTalkController.php&lt;/span&gt;

&lt;span class="nv"&gt;$dto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ConferenceTalkDto&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nv"&gt;$form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;createForm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;ConferenceTalkType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;$dto&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$form&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;handleRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$form&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isSubmitted&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$form&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isValid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$talk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$objectMapper&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;$dto&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;ConferenceTalk&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="nv"&gt;$entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$talk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5. Update the Edit Controller
&lt;/h3&gt;

&lt;p&gt;Editing existing entities works too.&lt;/p&gt;

&lt;p&gt;Before:&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="c1"&gt;// src/Controller/ConferenceTalkController.php&lt;/span&gt;

&lt;span class="nv"&gt;$form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;createForm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;ConferenceTalkType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;$talk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$form&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;handleRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$form&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isSubmitted&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$form&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isValid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After:&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="c1"&gt;// src/Controller/ConferenceTalkController.php&lt;/span&gt;

&lt;span class="nv"&gt;$dto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$objectMapper&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;$talk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;ConferenceTalkDto&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="nv"&gt;$form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;createForm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;ConferenceTalkType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;$dto&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$form&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;handleRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$form&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isSubmitted&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$form&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isValid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$objectMapper&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;$dto&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$talk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what's happening.&lt;/p&gt;

&lt;p&gt;For creation, &lt;code&gt;ObjectMapper&lt;/code&gt; creates a new entity from the DTO.&lt;/p&gt;

&lt;p&gt;For editing, &lt;code&gt;ObjectMapper&lt;/code&gt; first creates a DTO from the entity so the form&lt;br&gt;
has something to edit. Once validation succeeds, it maps the DTO back onto&lt;br&gt;
the existing entity.&lt;/p&gt;

&lt;p&gt;The pattern remains consistent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Forms work with DTOs&lt;/li&gt;
&lt;li&gt;Validation runs on DTOs&lt;/li&gt;
&lt;li&gt;Entities remain valid application data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most importantly, we no longer have a period of time when a &lt;code&gt;ConferenceTalk&lt;/code&gt; exists without a title!&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Want to see all of these changes together?&lt;br&gt;
Check out the &lt;a href="https://github.com/kbond/honest-entities/compare/b2e573ab37e40b9b74f542249cfb3fc96427b296...bf9fc872d6135d8431af932808ce7a620b6cbc47" rel="noopener noreferrer"&gt;full diff&lt;/a&gt; to see this pattern applied to a real Symfony application.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  ObjectMapper Changes the Equation
&lt;/h2&gt;

&lt;p&gt;For years, we've accepted a contradiction.&lt;/p&gt;

&lt;p&gt;The validator says required.&lt;/p&gt;

&lt;p&gt;The database says required.&lt;/p&gt;

&lt;p&gt;The entity says optional.&lt;/p&gt;

&lt;p&gt;We allow entities to exist in invalid states because they also need to act as form models. We make required properties nullable. We rely on validation to enforce requirements that our type system cannot express.&lt;/p&gt;

&lt;p&gt;The tradeoff made sense when entities doubled as form models.&lt;/p&gt;

&lt;p&gt;But ObjectMapper changes the equation.&lt;/p&gt;

&lt;p&gt;To be fair, this idea isn't entirely new.&lt;/p&gt;

&lt;p&gt;Many Symfony developers have used DTOs for years, often with custom mapping&lt;br&gt;
code or libraries such as &lt;a href="https://github.com/SymfonyCasts/micro-mapper" rel="noopener noreferrer"&gt;&lt;code&gt;symfonycasts/micro-mapper&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The challenge has never been whether DTOs are useful. The challenge has been standardization.&lt;/p&gt;

&lt;p&gt;ObjectMapper brings this capability into Symfony itself, giving the ecosystem a first-class solution for moving data between DTOs and entities.&lt;/p&gt;

&lt;p&gt;For the first time, this pattern feels practical enough to be considered a&lt;br&gt;
mainstream Symfony approach rather than an advanced architectural choice.&lt;/p&gt;

&lt;p&gt;That allows each object to focus on a single responsibility:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DTOs receive and validate user input&lt;/li&gt;
&lt;li&gt;Entities represent valid application data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is a model where required data is actually required, optional data is actually optional, and entities can no longer be created in impossible states.&lt;/p&gt;

&lt;p&gt;At least to me, that feels like a cleaner approach.&lt;/p&gt;

&lt;p&gt;Should MakerBundle generate DTOs by default? Should &lt;code&gt;make:crud&lt;/code&gt; offer a&lt;br&gt;
DTO/ObjectMapper workflow? Have you already been using a pattern like this in your applications?&lt;/p&gt;

&lt;p&gt;Maybe it's time for our entities to start telling the truth.&lt;/p&gt;

&lt;p&gt;What do you think? Let us know in the comments!&lt;/p&gt;




&lt;p&gt;Originally posted on &lt;a href="https://symfonycasts.com/blog/honest-entities" rel="noopener noreferrer"&gt;https://symfonycasts.com/blog/honest-entities&lt;/a&gt;&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>php</category>
      <category>doctrine</category>
      <category>oop</category>
    </item>
  </channel>
</rss>
