<?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>Forem: Ahmed EBEN HASSINE 脳の流れ</title>
    <description>The latest articles on Forem by Ahmed EBEN HASSINE 脳の流れ (@ahmedbhs).</description>
    <link>https://forem.com/ahmedbhs</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1216176%2F53fc6bd4-9bd0-493b-b9a0-31bcd9138bc7.jpeg</url>
      <title>Forem: Ahmed EBEN HASSINE 脳の流れ</title>
      <link>https://forem.com/ahmedbhs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/ahmedbhs"/>
    <language>en</language>
    <item>
      <title>Doctrine ORM: How I Escaped the Cartesian Product Trap</title>
      <dc:creator>Ahmed EBEN HASSINE 脳の流れ</dc:creator>
      <pubDate>Tue, 24 Feb 2026 14:41:45 +0000</pubDate>
      <link>https://forem.com/ahmedbhs/doctrine-orm-how-i-escaped-the-cartesian-product-trap-4mck</link>
      <guid>https://forem.com/ahmedbhs/doctrine-orm-how-i-escaped-the-cartesian-product-trap-4mck</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.amazonaws.com%2Fuploads%2Farticles%2Fzrxm0ymt4pu0xoanryox.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%2Fzrxm0ymt4pu0xoanryox.png" width="800" height="312"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Snippet from a client project; property names anonymized for confidentiality.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I opened the Symfony profiler on my catalog page and saw it: &lt;strong&gt;201 SQL queries&lt;/strong&gt;. For just 50 products. Development load time? 3 seconds. In production, that’s a ticking time bomb, waiting for the first traffic spike to explode.&lt;/p&gt;

&lt;p&gt;If you use Doctrine, you’ve probably run into the &lt;strong&gt;N+1&lt;/strong&gt; monster. But in the rush to defeat it, many developers stumble into an even more vicious "final boss": the &lt;strong&gt;Cartesian Product&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here’s how I optimized hydration without killing performance.&lt;/p&gt;


&lt;h3&gt;
  
  
  1. Diagnosing N+1 in the Wild
&lt;/h3&gt;

&lt;p&gt;My &lt;code&gt;Product&lt;/code&gt; entity is standard but data-heavy, with four main relationships:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;categories (ManyToMany)&lt;/li&gt;
&lt;li&gt;tags (ManyToMany)&lt;/li&gt;
&lt;li&gt;images (OneToMany)&lt;/li&gt;
&lt;li&gt;reviews (OneToMany)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code looks innocent enough:&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;$products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$productRepository&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;findBy&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'active'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But in the Twig template, it’s a bloodbath. Every time the template accesses &lt;code&gt;product.categories&lt;/code&gt; or &lt;code&gt;product.tags&lt;/code&gt;, Doctrine hits the database again.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;em&gt;Quick math:&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;1 initial query (products) + (50 products × 4 relations) =&lt;/em&gt; &lt;strong&gt;&lt;em&gt;201 queries&lt;/em&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  2. The Pitfalls of EAGER Fetching with Collections
&lt;/h3&gt;

&lt;p&gt;My first instinct? Set the relations to &lt;code&gt;fetch: 'EAGER'&lt;/code&gt;. Result? The same number of queries—just triggered earlier, in the controller instead of the template.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson learned:&lt;/strong&gt; &lt;code&gt;EAGER&lt;/code&gt; fetching works well for &lt;strong&gt;ToOne&lt;/strong&gt; relationships (ManyToOne, OneToOne) but behaves differently with &lt;strong&gt;collections&lt;/strong&gt; (OneToMany, ManyToMany). For collections, Doctrine typically runs extra queries per association—essentially "N+1 in disguise."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; &lt;code&gt;LAZY&lt;/code&gt; vs &lt;code&gt;EAGER&lt;/code&gt; controls &lt;em&gt;when&lt;/em&gt; data is loaded, not always &lt;em&gt;how&lt;/em&gt;. For collections, &lt;code&gt;EAGER&lt;/code&gt; rarely solves N+1; it just moves the timing around.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  3. The Hidden Cost of Cartesian Products
&lt;/h3&gt;

&lt;p&gt;"Fine," I thought, "I’ll just &lt;code&gt;leftJoin&lt;/code&gt; and &lt;code&gt;addSelect&lt;/code&gt; everything in a single Query Builder call."&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;createQueryBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p'&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;leftJoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p.categories'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'c'&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;addSelect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'c'&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;leftJoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p.tags'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'t'&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;addSelect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'t'&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;leftJoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p.images'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'i'&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;addSelect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'i'&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;leftJoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p.reviews'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'r'&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;addSelect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'r'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Profiler shows "1 query." Victory? Not really.&lt;/strong&gt; The page was slower. The culprit: the &lt;strong&gt;Cartesian Product&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Imagine a product with 3 categories and 4 images. SQL can’t return nested arrays in a column, so it flattens everything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;3 categories × 4 images = &lt;strong&gt;12 rows&lt;/strong&gt; for one product&lt;/li&gt;
&lt;li&gt;Add 5 tags and 10 reviews: 3 × 4 × 5 × 10 = &lt;strong&gt;600 rows&lt;/strong&gt; for &lt;strong&gt;a single product&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For 50 products, that’s over &lt;strong&gt;30,000 rows&lt;/strong&gt;. Doctrine chokes trying to deduplicate this data to hydrate your objects. Multiplicative explosion occurs whenever multiple collections are joined.&lt;/p&gt;




&lt;h3&gt;
  
  
  4. The Solution: Multi-Step Hydration
&lt;/h3&gt;

&lt;p&gt;This is where Doctrine’s &lt;strong&gt;Identity Map&lt;/strong&gt; shines. Instead of one massive join, break the loading into logical steps:&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;findActiveWithRelations&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Step 1: Load products + categories&lt;/span&gt;
    &lt;span class="nv"&gt;$products&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;createQueryBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p'&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;addSelect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'c'&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;leftJoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p.categories'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'c'&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p.active = true'&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;getQuery&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;getResult&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="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$products&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

    &lt;span class="c1"&gt;// Step 2: Prime tags into the Identity Map&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;createQueryBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p'&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;addSelect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'t'&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;leftJoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p.tags'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'t'&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p.active = true'&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;getQuery&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;getResult&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Step 3: Prime images&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;createQueryBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p'&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;addSelect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'i'&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;leftJoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p.images'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'i'&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p.active = true'&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;getQuery&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;getResult&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Step 4: Prime reviews&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;createQueryBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p'&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;addSelect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'r'&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;leftJoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p.reviews'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'r'&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p.active = true'&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;getQuery&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;getResult&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;$products&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;
  
  
  Why This Works
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Query 1:&lt;/strong&gt; Loads 50 products + categories.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subsequent queries:&lt;/strong&gt; Load related entities. Doctrine tracks product IDs in memory via the &lt;strong&gt;Identity Map&lt;/strong&gt;. Even if you don’t assign the result, Doctrine hydrates the entities and attaches relations to the existing objects.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; 4 lightweight, predictable queries, no data duplication.&lt;br&gt;
&lt;strong&gt;Pro Tip:&lt;/strong&gt; Make sure each step uses the exact same &lt;code&gt;WHERE&lt;/code&gt; filters. For complex cases (e.g., pagination), it’s safer to extract IDs from the first query and use &lt;code&gt;WHERE p.id IN (:ids)&lt;/code&gt; for subsequent queries.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  5. Automate to Prevent Regressions
&lt;/h3&gt;

&lt;p&gt;Tracking queries manually is tedious. I now use &lt;a href="https://github.com/ahmed-bhs/doctrine-doctor" rel="noopener noreferrer"&gt;doctrine-doctor&lt;/a&gt; to stay ahead.&lt;/p&gt;

&lt;p&gt;It integrates with the Symfony Web Profiler and alerts you to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Obvious &lt;strong&gt;N+1 patterns&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Excessive hydration&lt;/strong&gt; (Cartesian products)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing indexes&lt;/strong&gt; on join columns
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require &lt;span class="nt"&gt;--dev&lt;/span&gt; ahmed-bhs/doctrine-doctor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Final Lesson:&lt;/strong&gt; In ORM optimization, "less" isn’t always "better." Reducing 201 queries to 1 can crash your server via a Cartesian product. Reducing 201 to 4 can save it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;References:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;GitHub Issue doctrine/orm#4762&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>symfony</category>
      <category>doctrine</category>
      <category>performance</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Doctrine ORM: How I Escaped the Cartesian Product Trap</title>
      <dc:creator>Ahmed EBEN HASSINE 脳の流れ</dc:creator>
      <pubDate>Tue, 24 Feb 2026 08:09:34 +0000</pubDate>
      <link>https://forem.com/ahmedbhs/doctrine-orm-how-i-escaped-the-cartesian-product-trap-4f8e</link>
      <guid>https://forem.com/ahmedbhs/doctrine-orm-how-i-escaped-the-cartesian-product-trap-4f8e</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.amazonaws.com%2Fuploads%2Farticles%2Fzrxm0ymt4pu0xoanryox.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%2Fzrxm0ymt4pu0xoanryox.png" width="800" height="312"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Snippet from a client project; property names changed for confidentiality.&lt;/em&gt;&lt;br&gt;
I started by opening the Symfony profiler on my catalog page and there it was: &lt;strong&gt;201 SQL queries&lt;/strong&gt;. For just 50 products. Development load time? 3 seconds. In production, that’s a ticking time bomb, waiting for the first traffic spike to explode.&lt;/p&gt;

&lt;p&gt;If you use Doctrine, you’ve probably run into the &lt;strong&gt;N+1&lt;/strong&gt; monster. But in the rush to defeat it, many developers stumble into an even more vicious "final boss": the &lt;strong&gt;Cartesian Product&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here’s how I optimized hydration without killing performance.&lt;/p&gt;


&lt;h3&gt;
  
  
  1. Diagnosing N+1 in the Wild
&lt;/h3&gt;

&lt;p&gt;My &lt;code&gt;Product&lt;/code&gt; entity is standard but data-heavy, with four main relationships:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;categories (ManyToMany)&lt;/li&gt;
&lt;li&gt;tags (ManyToMany)&lt;/li&gt;
&lt;li&gt;images (OneToMany)&lt;/li&gt;
&lt;li&gt;reviews (OneToMany)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code looks innocent enough:&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;$products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$productRepository&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;findBy&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'active'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But in the Twig template, it’s a bloodbath. Every time the template accesses &lt;code&gt;product.categories&lt;/code&gt; or &lt;code&gt;product.tags&lt;/code&gt;, Doctrine hits the database again.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;em&gt;Quick math:&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;1 initial query (products) + (50 products × 4 relations) =&lt;/em&gt; &lt;strong&gt;&lt;em&gt;201 queries&lt;/em&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  2. The Pitfalls of EAGER Fetching with Collections
&lt;/h3&gt;

&lt;p&gt;My first instinct? Set the relations to &lt;code&gt;fetch: 'EAGER'&lt;/code&gt;. Result? The same number of queries—just triggered earlier, in the controller instead of the template.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson learned:&lt;/strong&gt; &lt;code&gt;EAGER&lt;/code&gt; fetching works well for &lt;strong&gt;ToOne&lt;/strong&gt; relationships (ManyToOne, OneToOne) but behaves differently with &lt;strong&gt;collections&lt;/strong&gt; (OneToMany, ManyToMany). For collections, Doctrine typically runs extra queries per association—essentially "N+1 in disguise."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; &lt;code&gt;LAZY&lt;/code&gt; vs &lt;code&gt;EAGER&lt;/code&gt; controls &lt;em&gt;when&lt;/em&gt; data is loaded, not always &lt;em&gt;how&lt;/em&gt;. For collections, &lt;code&gt;EAGER&lt;/code&gt; rarely solves N+1; it just moves the timing around.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  3. The Hidden Cost of Cartesian Products
&lt;/h3&gt;

&lt;p&gt;"Fine," I thought, "I’ll just &lt;code&gt;leftJoin&lt;/code&gt; and &lt;code&gt;addSelect&lt;/code&gt; everything in a single Query Builder call."&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;createQueryBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p'&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;leftJoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p.categories'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'c'&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;addSelect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'c'&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;leftJoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p.tags'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'t'&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;addSelect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'t'&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;leftJoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p.images'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'i'&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;addSelect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'i'&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;leftJoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p.reviews'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'r'&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;addSelect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'r'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Profiler shows "1 query." Victory? Not really.&lt;/strong&gt; The page was slower. The culprit: the &lt;strong&gt;Cartesian Product&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Imagine a product with 3 categories and 4 images. SQL can’t return nested arrays in a column, so it flattens everything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;3 categories × 4 images = &lt;strong&gt;12 rows&lt;/strong&gt; for one product&lt;/li&gt;
&lt;li&gt;Add 5 tags and 10 reviews: 3 × 4 × 5 × 10 = &lt;strong&gt;600 rows&lt;/strong&gt; for &lt;strong&gt;a single product&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For 50 products, that’s over &lt;strong&gt;30,000 rows&lt;/strong&gt;. Doctrine chokes trying to deduplicate this data to hydrate your objects. Multiplicative explosion occurs whenever multiple collections are joined.&lt;/p&gt;




&lt;h3&gt;
  
  
  4. The Solution: Multi-Step Hydration
&lt;/h3&gt;

&lt;p&gt;This is where Doctrine’s &lt;strong&gt;Identity Map&lt;/strong&gt; shines. Instead of one massive join, break the loading into logical steps:&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;findActiveWithRelations&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Step 1: Load products + categories&lt;/span&gt;
    &lt;span class="nv"&gt;$products&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;createQueryBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p'&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;addSelect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'c'&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;leftJoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p.categories'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'c'&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p.active = true'&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;getQuery&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;getResult&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="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$products&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

    &lt;span class="c1"&gt;// Step 2: Prime tags into the Identity Map&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;createQueryBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p'&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;addSelect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'t'&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;leftJoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p.tags'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'t'&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p.active = true'&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;getQuery&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;getResult&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Step 3: Prime images&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;createQueryBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p'&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;addSelect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'i'&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;leftJoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p.images'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'i'&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p.active = true'&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;getQuery&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;getResult&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Step 4: Prime reviews&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;createQueryBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p'&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;addSelect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'r'&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;leftJoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p.reviews'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'r'&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p.active = true'&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;getQuery&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;getResult&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;$products&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;
  
  
  Why This Works
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Query 1:&lt;/strong&gt; Loads 50 products + categories.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subsequent queries:&lt;/strong&gt; Load related entities. Doctrine tracks product IDs in memory via the &lt;strong&gt;Identity Map&lt;/strong&gt;. Even if you don’t assign the result, Doctrine hydrates the entities and attaches relations to the existing objects.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; 4 lightweight, predictable queries, no data duplication.&lt;br&gt;
&lt;strong&gt;Pro Tip:&lt;/strong&gt; Make sure each step uses the exact same &lt;code&gt;WHERE&lt;/code&gt; filters. For complex cases (e.g., pagination), it’s safer to extract IDs from the first query and use &lt;code&gt;WHERE p.id IN (:ids)&lt;/code&gt; for subsequent queries.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  5. Automate to Prevent Regressions
&lt;/h3&gt;

&lt;p&gt;Tracking queries manually is tedious. I now use &lt;a href="https://github.com/ahmed-bhs/doctrine-doctor" rel="noopener noreferrer"&gt;doctrine-doctor&lt;/a&gt; to stay ahead.&lt;/p&gt;

&lt;p&gt;It integrates with the Symfony Web Profiler and alerts you to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Obvious &lt;strong&gt;N+1 patterns&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Excessive hydration&lt;/strong&gt; (Cartesian products)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing indexes&lt;/strong&gt; on join columns
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require &lt;span class="nt"&gt;--dev&lt;/span&gt; ahmed-bhs/doctrine-doctor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Final Lesson:&lt;/strong&gt; In ORM optimization, "less" isn’t always "better." Reducing 201 queries to 1 can crash your server via a Cartesian product. Reducing 201 to 4 can save it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;References:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;GitHub Issue doctrine/orm#4762&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>symfony</category>
      <category>doctrine</category>
      <category>performance</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>How Traditional Controllers Violate SRP</title>
      <dc:creator>Ahmed EBEN HASSINE 脳の流れ</dc:creator>
      <pubDate>Tue, 16 Sep 2025 07:02:02 +0000</pubDate>
      <link>https://forem.com/ahmedbhs/how-traditional-controllers-violate-srp-13e0</link>
      <guid>https://forem.com/ahmedbhs/how-traditional-controllers-violate-srp-13e0</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.amazonaws.com%2Fuploads%2Farticles%2F8m6nxzk3zqito3mybum2.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%2F8m6nxzk3zqito3mybum2.png" width="800" height="590"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  How Traditional Controllers Violate SRP
&lt;/h3&gt;

&lt;p&gt;Every Symfony developer has been there: you open a controller file expecting a quick fix, only to find a 500-line monster with 15 methods and 10 injected dependencies. What went wrong? As someone who audits projects for external companies, I see this pattern pop up constantly in growing Symfony applications.&lt;/p&gt;

&lt;p&gt;What starts as a simple ProductController can quickly morph into a monolithic &lt;strong&gt;God Object&lt;/strong&gt;  — a class that knows too much, does too much, and becomes a nightmare to maintain, test, and scale.&lt;/p&gt;

&lt;p&gt;Even though Symfony gives us some nice tools like autowiring and dependency injection through the argument resolver, that alone isn’t enough. You still need solid design patterns to really guide developers and help them build controllers that are clean, lightweight, and easy to maintain. A controller with 15 actions might end up injecting 10 different services, even though each method only uses a few of them. That’s a classic symptom of low cohesion and high coupling: the class isn’t a unified whole, it’s a convenient but messy collection of methods.&lt;/p&gt;

&lt;p&gt;While Symfony’s &lt;a href="https://symfony.com/doc/current/service_container/lazy_services.html" rel="noopener noreferrer"&gt;lazy loading&lt;/a&gt; mitigates the performance impact of injecting many services, the true cost is human. A constructor with ten dependencies signals a violation of SRP and imposes a heavy &lt;a href="https://github.com/zakirullin/cognitive-load" rel="noopener noreferrer"&gt;&lt;strong&gt;cognitive load&lt;/strong&gt;&lt;/a&gt; on any developer trying to understand or modify the class. It becomes impossible to know which dependencies are needed for which action without reading the entire file.&lt;/p&gt;

&lt;p&gt;The solution is both elegant and powerful: &lt;strong&gt;the Single Action Controller.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In fact, this isn’t just a community trend, it’s a pattern officially recommended in Symfony’s own best practices, especially for a common use case: having a single controller action that both renders a form and processes its submission.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Single Responsibility Principle (SRP), Applied Correctly to Controllers
&lt;/h3&gt;

&lt;p&gt;The Single Responsibility Principle (SRP), a precise rule that helps enforce a clear &lt;strong&gt;Separation of Concerns (SoC)&lt;/strong&gt;, is one of the most frequently misunderstood ideas in software design. Drawing from Robert C. Martin’s clarification, the principle isn’t about a class “doing only one thing.” Instead, it states:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;em&gt;“_A module should have a single reason to change and be the responsibility of exactly one actor&lt;/em&gt;.”_&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;An &lt;strong&gt;actor&lt;/strong&gt; is a group of people, a stakeholder, or a department that requires changes in the software. The SRP is about structuring our code to align with the structure of your organization. For controllers, this means a single controller class should serve the needs of exactly one of these actors.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the “Fat Controller” Violates SRP
&lt;/h3&gt;

&lt;p&gt;A “Fat Controller” is a classic SRP violation because it becomes a magnet for requests from multiple, unrelated business departments. It serves many actors, giving it many reasons to change.&lt;/p&gt;

&lt;p&gt;Let’s look at a typical OrderController:&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;OrderController&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;AbstractController&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Constructor with many dependencies for different actors...&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;OrderRepository&lt;/span&gt; &lt;span class="nv"&gt;$orderRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Used by Marketing/UX&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;PromotionManager&lt;/span&gt; &lt;span class="nv"&gt;$promotionManager&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Used by Marketing&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;PaymentGateway&lt;/span&gt; &lt;span class="nv"&gt;$paymentGateway&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Used by Finance&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;ShippingCalculator&lt;/span&gt; &lt;span class="nv"&gt;$shippingCalculator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Used by Logistics&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;MailerInterface&lt;/span&gt; &lt;span class="nv"&gt;$mailer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Used by Finance &amp;amp; Logistics&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;LoggerInterface&lt;/span&gt; &lt;span class="nv"&gt;$logger&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="c1"&gt;// Method for Actor 1: Marketing/UX Team&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;showCart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// Method for Actor 2: Finance Department&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;processPayment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// Method for Actor 3: Logistics Department&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;calculateShipping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&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;&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%2Fv8h2onur3axci9eewke4.jpeg" 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%2Fv8h2onur3axci9eewke4.jpeg" width="800" height="532"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This single class is forced to change for reasons driven by completely different parts of the business. It is responsible to three distinct actors:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Actor: The Marketing &amp;amp; UX Team&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Their Goal:&lt;/strong&gt; Optimize the user’s shopping experience to drive sales.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reason to Change:&lt;/strong&gt; They might request a new feature in the cart display, like adding promo codes or changing the layout. A change to the showCart method is required.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;2. Actor: The Finance Department&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Their Goal:&lt;/strong&gt; Ensure payments are processed securely and accurately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reason to Change:&lt;/strong&gt; They might decide to switch payment providers or add new fraud-detection logic. A change to the processPayment method is needed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;3. Actor: The Logistics Department&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Their Goal:&lt;/strong&gt; Fulfill and ship orders efficiently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reason to Change:&lt;/strong&gt; They could partner with a new shipping carrier or need to adjust the shipping cost calculation rules. This requires modifying the calculateShipping method.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The problem is that these unrelated reasons for change are tangled together in one class. A developer from the finance team modifying payment logic could accidentally introduce a bug that breaks the shipping calculator, affecting the logistics team. This creates &lt;strong&gt;high coupling&lt;/strong&gt; between organizational departments within the codebase, leading to merge conflicts, increased cognitive load, and a higher risk of regressions.&lt;/p&gt;

&lt;h3&gt;
  
  
  How Single-Action Controllers Enforce SRP
&lt;/h3&gt;

&lt;p&gt;By adopting Single-Action Controllers, you align your code structure with your organizational structure. Each controller is responsible to exactly one actor and handles the orchestration of a single use case.&lt;/p&gt;

&lt;h4&gt;
  
  
  1. Serving the Marketing &amp;amp; UX Team
&lt;/h4&gt;

&lt;p&gt;This controller’s sole reason to exist is to fulfill requests from the Marketing/UX team regarding the cart’s presentation.&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;#[Route('/cart', methods: ['GET'])]&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;ShowCartController&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;AbstractController&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;CartService&lt;/span&gt; &lt;span class="nv"&gt;$cartService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;PromotionService&lt;/span&gt; &lt;span class="nv"&gt;$promotionService&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;__invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Single responsibility: orchestrate cart display for the user&lt;/span&gt;
        &lt;span class="nv"&gt;$cartData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;cartService&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getCartForDisplay&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;getUser&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;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'cart/show.html.twig'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$cartData&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;ul&gt;
&lt;li&gt;
&lt;strong&gt;Single Actor &amp;amp; Reason to Change:&lt;/strong&gt; The &lt;strong&gt;Marketing Team&lt;/strong&gt; needs to alter the cart’s appearance or data. Changes are isolated here, with zero risk of affecting payments or shipping.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  2. Serving the Finance Department
&lt;/h4&gt;

&lt;p&gt;This controller is exclusively owned by the needs of the Finance department.&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;#[Route('/payment/process', methods: ['POST'])]&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;ProcessPaymentController&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;AbstractController&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;PaymentService&lt;/span&gt; &lt;span class="nv"&gt;$paymentService&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;__invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Single responsibility: orchestrate payment processing&lt;/span&gt;
        &lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;paymentService&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;processPayment&lt;/span&gt;&lt;span class="p"&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="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;all&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;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'success'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getId&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;ul&gt;
&lt;li&gt;
&lt;strong&gt;Single Actor &amp;amp; Reason to Change:&lt;/strong&gt; The &lt;strong&gt;Finance Department&lt;/strong&gt; needs to modify the payment workflow. This class can be changed independently of all other application features.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  3. Serving the Logistics Department
&lt;/h4&gt;

&lt;p&gt;This controller’s responsibility is to the Logistics team and no one else.&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;#[Route('/shipping/calculate', methods: ['POST'])]&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;CalculateShippingController&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;AbstractController&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;ShippingService&lt;/span&gt; &lt;span class="nv"&gt;$shippingService&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;__invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Single responsibility: orchestrate shipping cost calculation&lt;/span&gt;
        &lt;span class="nv"&gt;$cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;shippingService&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;calculateShipping&lt;/span&gt;&lt;span class="p"&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="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;all&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;'cost'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$cost&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;ul&gt;
&lt;li&gt;
&lt;strong&gt;Single Actor &amp;amp; Reason to Change:&lt;/strong&gt; The &lt;strong&gt;Logistics Department&lt;/strong&gt; needs to update how shipping is calculated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By breaking up the “fat controller,” we’ve created small, focused classes. The dependencies of each controller are now a clear signal of which actor it serves. This design minimizes coupling, reduces the risk of unintended side effects, and makes the codebase dramatically easier to navigate and maintain. Your code’s structure now reflects and respects your organization’s structure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Radically Simplified Testing (This is a Game-Changer)
&lt;/h3&gt;

&lt;p&gt;This is where the pattern truly shines. Let’s compare the testing experience.&lt;/p&gt;

&lt;h3&gt;
  
  
  The “Fat Controller” Testing Nightmare:
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Testing the processPayment method requires mocking ALL dependencies&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;testProcessPayment&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Need to mock services that have nothing to do with payment processing&lt;/span&gt;
    &lt;span class="nv"&gt;$orderRepository&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;createMock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderRepository&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;$shippingCalculator&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;createMock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ShippingCalculator&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="c1"&gt;// Not needed!&lt;/span&gt;
    &lt;span class="nv"&gt;$promotionManager&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;createMock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PromotionManager&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="c1"&gt;// Not needed!&lt;/span&gt;
    &lt;span class="nv"&gt;$paymentGateway&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;createMock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PaymentGateway&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;$mailer&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;createMock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MailerInterface&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;$logger&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;createMock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LoggerInterface&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="c1"&gt;// Not needed!&lt;/span&gt;
    &lt;span class="nv"&gt;$controller&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;OrderController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nv"&gt;$orderRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;$paymentGateway&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;$shippingCalculator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;$promotionManager&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;$mailer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;$logger&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="c1"&gt;// Complex setup just to test one method...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problems: Noisy setup, fragility (change the constructor, break all tests), and high cognitive load.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Single Action Controller Testing Dream:
&lt;/h3&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;testProcessPayment&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// We mock the service, not its dependencies&lt;/span&gt;
    &lt;span class="nv"&gt;$paymentService&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;createMock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PaymentService&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="c1"&gt;// We can configure the mock if needed&lt;/span&gt;
    &lt;span class="c1"&gt;// $paymentService-&amp;gt;expects($this-&amp;gt;once())-&amp;gt;method('processPayment')-&amp;gt;willReturn(...);&lt;/span&gt;

    &lt;span class="nv"&gt;$controller&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;ProcessPaymentController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$paymentService&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$controller&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;createPaymentRequest&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;assertSame&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="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getStatusCode&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 benefits: Readability, robustness, and maintainability. You test behavior, not configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Addressing Common Concerns
&lt;/h3&gt;

&lt;h3&gt;
  
  
  “But doesn’t this create too many files?”
&lt;/h3&gt;

&lt;p&gt;Yes, you’ll have more files, but organization beats convenience every time. Would you rather have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One 500-line file that’s impossible to navigate, or&lt;/li&gt;
&lt;li&gt;Fifty 10-line files that are immediately understandable?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Modern IDEs make file navigation trivial, and the mental overhead of understanding a focused class is dramatically lower.&lt;/p&gt;

&lt;h3&gt;
  
  
  “What about code duplication?”
&lt;/h3&gt;

&lt;p&gt;Shared logic doesn’t belong in controllers anyway — it belongs in services, Command Handlers. Single Action Controllers actually encourage better architecture by making this obvious:&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;// Shared logic moves to services where it belongs&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderService&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;validateOrderData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$data&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="c1"&gt;// Validation logic used by multiple controllers&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;
  
  
  “Won’t this hurt performance?”
&lt;/h3&gt;

&lt;p&gt;Symfony’s lazy loading means classes are only instantiated when needed. The performance impact is negligible, and the maintainability gains far outweigh any theoretical overhead.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Bottom Line: An Architectural Win-Win
&lt;/h3&gt;

&lt;p&gt;Adopting Single Action Controllers isn’t just a syntactic preference; it’s a strategic architectural decision that prioritizes maintainability, robustness, and cleaner code.&lt;/p&gt;

&lt;p&gt;The “Fat Controller” is technical debt in disguise. Easy to write at first, but its hidden costs in testing time, debugging, and fragility compound quickly.&lt;/p&gt;

&lt;p&gt;The Single Action Controller requires a slight shift in mindset but pays immediate and lasting dividends. You get a codebase that’s easier for new developers to onboard, safer to refactor, and built to last.&lt;/p&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%2Ftcb3sresrumda9cgegb3.gif" 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%2Ftcb3sresrumda9cgegb3.gif" width="480" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For&lt;/strong&gt; a deeper dive into this architectural pattern, I highly recommend watching &lt;strong&gt;“&lt;/strong&gt; &lt;a href="https://www.youtube.com/watch?v=MF0jFKvS4SI" rel="noopener noreferrer"&gt;&lt;strong&gt;Cruddy by Design”&lt;/strong&gt;&lt;/a&gt; by Adam Wathan from &lt;strong&gt;Laracon&lt;/strong&gt; US 2017.&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>softwaredevelopment</category>
      <category>laravel</category>
      <category>php</category>
    </item>
  </channel>
</rss>
