<?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: Alkin Veysal</title>
    <description>The latest articles on DEV Community by Alkin Veysal (@alkin).</description>
    <link>https://dev.to/alkin</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4089518%2F9aa49dfb-3831-4e4b-8b68-739c1930ebaf.jpg</url>
      <title>DEV Community: Alkin Veysal</title>
      <link>https://dev.to/alkin</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alkin"/>
    <language>en</language>
    <item>
      <title>Poka-Yoke in PHP: Guarding Business Operations Before They Run</title>
      <dc:creator>Alkin Veysal</dc:creator>
      <pubDate>Thu, 22 Oct 2026 07:20:47 +0000</pubDate>
      <link>https://dev.to/alkin/poka-yoke-in-php-guarding-business-operations-before-they-run-427a</link>
      <guid>https://dev.to/alkin/poka-yoke-in-php-guarding-business-operations-before-they-run-427a</guid>
      <description>&lt;p&gt;In manufacturing, &lt;em&gt;Poka-Yoke&lt;/em&gt; is a way of designing a process so that common mistakes are prevented or detected before they cause harm. An asymmetric fixture that accepts a component in only one orientation is a familiar example.&lt;/p&gt;

&lt;p&gt;I work with software in operational environments, and I kept returning to a related question: &lt;strong&gt;what does mistake-proofing look like at the boundary of a PHP business operation?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not every mistake needs a new library. Most of the time, an ordinary &lt;code&gt;if&lt;/code&gt; statement is the right answer. But when an operation depends on several independent conditions, it can become useful to make those checks explicit, collect the reasons for rejection, and keep the action itself behind a single guard.&lt;/p&gt;

&lt;p&gt;That experiment became &lt;a href="https://github.com/alkinbg/Novora-PokaYoke" rel="noopener noreferrer"&gt;Novora Poka-Yoke&lt;/a&gt;, a small, framework-independent PHP 8.4+ package.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem is not just validation
&lt;/h2&gt;

&lt;p&gt;Consider a shipping operation. Before dispatching, we may need to know that payment has been confirmed, a shipping address exists, and sufficient stock is available.&lt;/p&gt;

&lt;p&gt;We can write these conditions inline. There is nothing inherently wrong with that. But a sequence of early returns often tells the user only about the &lt;strong&gt;first&lt;/strong&gt; missing condition, while the same requirements can end up scattered across controllers, command handlers, and services.&lt;/p&gt;

&lt;p&gt;For some workflows, I wanted an explicit boundary:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Evaluate application-defined preconditions.&lt;/li&gt;
&lt;li&gt;Collect understandable, machine-readable reasons if any fail.&lt;/li&gt;
&lt;li&gt;Invoke the operation callback only when every check passes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The important distinction is that this is &lt;strong&gt;operation precondition checking&lt;/strong&gt;, not a general data-validation framework, a state machine, or a transaction manager.&lt;/p&gt;

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

&lt;p&gt;Install the stable package with Composer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require &lt;span class="s2"&gt;"novora/poka-yoke:^1.0"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A rule implements one method and returns either &lt;code&gt;null&lt;/code&gt; (allowed) or a &lt;code&gt;Violation&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Novora\PokaYoke\Rule&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Novora\PokaYoke\Violation&lt;/span&gt;&lt;span class="p"&gt;;&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;PaymentConfirmed&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Rule&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;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;mixed&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?Violation&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="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$context&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InvalidArgumentException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Expected an Order.'&lt;/span&gt;&lt;span class="p"&gt;);&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;$context&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isPaid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;null&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;Violation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'payment.not_confirmed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Payment must be confirmed.'&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;&lt;code&gt;Order&lt;/code&gt; is a placeholder for an entity or DTO &lt;strong&gt;from your application&lt;/strong&gt;; the package does not define an &lt;code&gt;Order&lt;/code&gt; class.&lt;/p&gt;

&lt;p&gt;We can combine multiple rules without hiding which ones ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Novora\PokaYoke\Guard&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$guard&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;Guard&lt;/span&gt;&lt;span class="p"&gt;();&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;$guard&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PaymentConfirmed&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ShippingAddressPresent&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InventoryAvailable&lt;/span&gt;&lt;span class="p"&gt;(),&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="o"&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;allowed&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&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="n"&gt;violations&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$violation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Application decides how to present or translate the message.&lt;/span&gt;
        &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$violation&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;': '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$violation&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="kc"&gt;PHP_EOL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two additional rule classes above are application-defined examples too. By default, &lt;code&gt;check()&lt;/code&gt; collects violations in rule order. For cases where evaluating later rules would be unnecessary, it supports &lt;code&gt;stopOnFirstViolation: true&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put the action behind the guard
&lt;/h2&gt;

&lt;p&gt;A separate check followed by an action is useful for displaying diagnostics, but the package also offers a compact operation boundary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Novora\PokaYoke\Guard&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Novora\PokaYoke\OperationBlocked&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$shipmentId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Guard&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;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PaymentConfirmed&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ShippingAddressPresent&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InventoryAvailable&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
        &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$shippingService&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OperationBlocked&lt;/span&gt; &lt;span class="nv"&gt;$exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$exception&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;violations&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$violation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Map stable codes to your API, logs, or UI.&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 callback is not invoked if any rule rejects the operation. &lt;code&gt;execute()&lt;/code&gt; returns the callback's value if all rules pass. Exceptions thrown by a rule or the callback are &lt;strong&gt;not swallowed&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;One design decision here is deliberate: a rule expresses &lt;em&gt;why the operation cannot proceed&lt;/em&gt;, while the application remains responsible for domain decisions and persistence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this pattern is useful
&lt;/h2&gt;

&lt;p&gt;The same narrow concept appears in quite different applications:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shipping:&lt;/strong&gt; payment, address, and stock checks before dispatch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manufacturing:&lt;/strong&gt; verifying a scanned part and a completed quality step before recording the next operation. Real machinery safety must remain in dedicated safety systems.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bookings:&lt;/strong&gt; checking consent and requested capacity before submitting a booking.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are not promises of safe concurrent inventory or seat allocation. They are examples of where explicit preconditions make intent easier to understand.&lt;/p&gt;

&lt;p&gt;The repository includes a &lt;a href="https://github.com/alkinbg/Novora-PokaYoke/blob/v1.0.0/examples/order.php" rel="noopener noreferrer"&gt;runnable order example&lt;/a&gt; and a &lt;a href="https://github.com/alkinbg/Novora-PokaYoke/blob/main/docs/practical-value.md" rel="noopener noreferrer"&gt;discussion of practical scenarios and alternatives&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Poka-Yoke cannot guarantee here
&lt;/h2&gt;

&lt;p&gt;This is the part I consider especially important.&lt;/p&gt;

&lt;p&gt;A successful precondition check does &lt;strong&gt;not&lt;/strong&gt; guarantee the state will still be valid when the callback commits its changes. Inventory, account balance, or booking capacity can change concurrently. The library does not provide database transactions, row locking, idempotency, distributed coordination, or rollback.&lt;/p&gt;

&lt;p&gt;If correctness depends on the check and write being atomic, the application must enforce that with suitable database constraints, transactions, locks, or other domain-specific mechanisms. Likewise, rule implementations should avoid side effects, but the library cannot prevent user-supplied PHP code from mutating state.&lt;/p&gt;

&lt;p&gt;For data shape and field constraints, an established validator is probably a better choice. For state transitions, a workflow or state-machine component is usually a better abstraction. And for one or two straightforward preconditions, plain PHP conditionals may be simpler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing the contract
&lt;/h2&gt;

&lt;p&gt;The initial &lt;code&gt;v1.0.0&lt;/code&gt; release was validated with &lt;strong&gt;15 PHPUnit tests (27 assertions)&lt;/strong&gt;, PHPStan level 8, Composer validation, and CI on PHP 8.4 and PHP 8.5. I also installed the published package from Packagist in a separate PHP project and exercised its API.&lt;/p&gt;

&lt;p&gt;Tests cover failing preconditions, the order of violations, fail-fast checking, invalid rule values, propagation of exceptions, falsy contexts, and the guarantee that a rejected operation does not invoke its callback.&lt;/p&gt;

&lt;p&gt;Those checks establish the package's &lt;strong&gt;documented behavior&lt;/strong&gt;. They are not proof that every consuming application's business rules are correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  A modest conclusion
&lt;/h2&gt;

&lt;p&gt;I do not think Poka-Yoke in PHP requires a large framework. Sometimes it is simply a useful design habit: &lt;strong&gt;make the conditions for a consequential operation explicit, and stop before the side effect when those conditions are not met&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Novora Poka-Yoke packages one small expression of that idea. It might be useful when you want structured rejection reasons and a consistent execution boundary; it is not intended to replace a validator, workflow engine, or carefully designed domain model.&lt;/p&gt;

&lt;p&gt;If you have solved similar problems differently, I would be interested to learn what worked for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Project links:&lt;/strong&gt; &lt;a href="https://github.com/alkinbg/Novora-PokaYoke" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://packagist.org/packages/novora/poka-yoke" rel="noopener noreferrer"&gt;Packagist&lt;/a&gt; · &lt;a href="https://github.com/alkinbg/Novora-PokaYoke/releases/tag/v1.0.0" rel="noopener noreferrer"&gt;v1.0.0 release&lt;/a&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>opensource</category>
      <category>programming</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Bulk Editing in EasyAdmin 5: A Review-and-Confirm Workflow for Doctrine Entities</title>
      <dc:creator>Alkin Veysal</dc:creator>
      <pubDate>Thu, 22 Oct 2026 05:14:28 +0000</pubDate>
      <link>https://dev.to/alkin/bulk-editing-in-easyadmin-5-a-review-and-confirm-workflow-for-doctrine-entities-5bhf</link>
      <guid>https://dev.to/alkin/bulk-editing-in-easyadmin-5-a-review-and-confirm-workflow-for-doctrine-entities-5bhf</guid>
      <description>&lt;p&gt;EasyAdmin is one of the reasons building administrative interfaces with Symfony can be pleasantly straightforward. It gives us CRUD pages, filters, forms, actions, and a lot of useful defaults without making every project reinvent its back office.&lt;/p&gt;

&lt;p&gt;But there is a small workflow I have needed in real applications: &lt;strong&gt;select several records and change only a few explicitly chosen fields&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Imagine an administrator managing a product catalogue. Twenty products need to move from &lt;em&gt;draft&lt;/em&gt; to &lt;em&gt;published&lt;/em&gt;. Their titles, prices, categories, and other attributes must remain untouched. Opening twenty edit forms is tedious. A custom one-off batch action works, but each additional field or entity often means another controller method and another form.&lt;/p&gt;

&lt;p&gt;I wanted to explore whether that common workflow could be packaged as a focused EasyAdmin extension. The result is &lt;a href="https://github.com/alkinbg/NovoraEasyAdminBulkEditBundle" rel="noopener noreferrer"&gt;Novora EasyAdmin Bulk Edit Bundle&lt;/a&gt;, with its latest published release, &lt;strong&gt;v1.1.3&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This post is about the decisions behind it, how to wire it into an EasyAdmin 5 CRUD controller, and—just as importantly—what the bundle does &lt;strong&gt;not&lt;/strong&gt; attempt to solve.&lt;/p&gt;

&lt;h2&gt;
  
  
  The difference between a batch action and bulk editing
&lt;/h2&gt;

&lt;p&gt;EasyAdmin already supports batch actions. You can select multiple rows and invoke a custom action. That is an excellent extension point; the bundle uses it rather than replacing EasyAdmin's index UI.&lt;/p&gt;

&lt;p&gt;What a generic &lt;em&gt;bulk edit&lt;/em&gt; workflow needs on top of that is a little more involved:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Collect the selected records from the batch action.&lt;/li&gt;
&lt;li&gt;Let the administrator choose &lt;strong&gt;which fields&lt;/strong&gt; to change, and supply their new values.&lt;/li&gt;
&lt;li&gt;Validate those values with Symfony Forms.&lt;/li&gt;
&lt;li&gt;Display the proposed changes before anything is written.&lt;/li&gt;
&lt;li&gt;Check permissions again when the final confirmation arrives.&lt;/li&gt;
&lt;li&gt;Apply the changes through Doctrine ORM within a transaction.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The second point is easy to underestimate. A bulk edit form must distinguish &lt;strong&gt;“leave this field unchanged”&lt;/strong&gt; from &lt;strong&gt;“set this field to &lt;code&gt;null&lt;/code&gt;, &lt;code&gt;false&lt;/code&gt;, an empty string, or zero.”&lt;/strong&gt; Those values can be perfectly valid updates.&lt;/p&gt;

&lt;p&gt;For that reason, the form gives each editable field its own opt-in checkbox. The new value matters only when the checkbox is selected. Unchecked fields are omitted from the change set entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  A deliberately small scope
&lt;/h2&gt;

&lt;p&gt;The bundle targets PHP 8.4+, Symfony 7.4/8.x, EasyAdmin 5.x, and Doctrine ORM 3.x. Version 1.x keeps its supported operation intentionally narrow: up to &lt;strong&gt;100 selected entity identifiers&lt;/strong&gt;, with explicit handlers for scalar values (&lt;code&gt;string&lt;/code&gt;, &lt;code&gt;int&lt;/code&gt;, &lt;code&gt;float&lt;/code&gt;, &lt;code&gt;bool&lt;/code&gt;, or &lt;code&gt;null&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;It is not an automatic editor for every Doctrine property. Arbitrary associations, collections, object-valued form data, and asynchronous processing are out of scope.&lt;/p&gt;

&lt;p&gt;That constraint is a feature of the design rather than a claim that those cases are unimportant. I would rather maintain a modest public API with understandable behavior than guess how every application's domain model should be mutated.&lt;/p&gt;

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

&lt;p&gt;The package is listed on &lt;a href="https://packagist.org/packages/novora/easyadmin-bulk-edit-bundle" rel="noopener noreferrer"&gt;Packagist&lt;/a&gt;. For a project meeting its requirements:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require novora/easyadmin-bulk-edit-bundle:^1.1.3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register the bundle if your application's setup does not enable it automatically:&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;// config/bundles.php&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;// Your existing bundles...&lt;/span&gt;
    &lt;span class="nc"&gt;Novora\EasyAdminBulkEditBundle\NovoraEasyAdminBulkEditBundle&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'all'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This assumes an existing, correctly configured EasyAdmin application with Symfony sessions, Twig, Doctrine, and CSRF protection. The registration snippet is for applications where the bundle is not already present in &lt;code&gt;config/bundles.php&lt;/code&gt;; &lt;strong&gt;do not add it twice&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting a CRUD controller
&lt;/h2&gt;

&lt;p&gt;The following example assumes a &lt;code&gt;Product&lt;/code&gt; entity with a &lt;code&gt;setStatus(string $status)&lt;/code&gt; method and an application voter that understands the &lt;code&gt;EDIT&lt;/code&gt; permission. Those are &lt;strong&gt;application-specific examples&lt;/strong&gt;, not classes supplied by the bundle.&lt;/p&gt;

&lt;p&gt;First, register an EasyAdmin batch action. &lt;code&gt;BulkEditAction::new()&lt;/code&gt; uses EasyAdmin’s normal batch-action mechanism and skips its generic preliminary confirmation &lt;strong&gt;for this action only&lt;/strong&gt;; the bundle still provides its own review and final confirmation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;EasyCorp\Bundle\EasyAdminBundle\Config\Actions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Novora\EasyAdminBulkEditBundle\BulkEditAction&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;configureActions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Actions&lt;/span&gt; &lt;span class="nv"&gt;$actions&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Actions&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;$actions&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addBatchAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;BulkEditAction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'bulkEdit'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Bulk edit'&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;Then add the two custom CRUD actions. With EasyAdmin 5, the &lt;code&gt;#[AdminRoute]&lt;/code&gt; attributes matter: they make these custom routes discoverable in its routing system.&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="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Controller\Admin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Entity\Product&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;EasyCorp\Bundle\EasyAdminBundle\Attribute\AdminRoute&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;EasyCorp\Bundle\EasyAdminBundle\Config\Action&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;EasyCorp\Bundle\EasyAdminBundle\Config\Actions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;EasyCorp\Bundle\EasyAdminBundle\Dto\BatchActionDto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Novora\EasyAdminBulkEditBundle\BulkEditAction&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Novora\EasyAdminBulkEditBundle\BulkEditField&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Novora\EasyAdminBulkEditBundle\Workflow\BulkEditWorkflow&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Form\Extension\Core\Type\ChoiceType&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\HttpFoundation\Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\HttpFoundation\Response&lt;/span&gt;&lt;span class="p"&gt;;&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;ProductCrudController&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;AbstractCrudController&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;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getEntityFqcn&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="nc"&gt;Product&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;configureActions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Actions&lt;/span&gt; &lt;span class="nv"&gt;$actions&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Actions&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;$actions&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addBatchAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;BulkEditAction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'bulkEdit'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Bulk edit'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="na"&gt;#[AdminRoute('/bulk-edit', name: 'bulk_edit', options: ['methods' =&amp;gt; ['POST']])]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;bulkEdit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;AdminContext&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;BatchActionDto&lt;/span&gt; &lt;span class="nv"&gt;$batchActionDto&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;BulkEditWorkflow&lt;/span&gt; &lt;span class="nv"&gt;$workflow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;AdminUrlGenerator&lt;/span&gt; &lt;span class="nv"&gt;$urls&lt;/span&gt;&lt;span class="p"&gt;,&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;denyAccessUnlessGranted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ROLE_PRODUCT_EDITOR'&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;$workflow&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nv"&gt;$batchActionDto&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getEntity&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;getFqcn&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="nv"&gt;$urls&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&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;setAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'bulkEditConfirm'&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;generateUrl&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="na"&gt;#[AdminRoute('/bulk-edit-confirm', name: 'bulk_edit_confirm', options: ['methods' =&amp;gt; ['GET', 'POST']])]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;bulkEditConfirm&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;AdminContext&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;BulkEditWorkflow&lt;/span&gt; &lt;span class="nv"&gt;$workflow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;AdminUrlGenerator&lt;/span&gt; &lt;span class="nv"&gt;$urls&lt;/span&gt;&lt;span class="p"&gt;,&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;denyAccessUnlessGranted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ROLE_PRODUCT_EDITOR'&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;$workflow&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;confirm&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="nv"&gt;$context&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getEntity&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;getFqcn&lt;/span&gt;&lt;span class="p"&gt;(),&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="p"&gt;[&lt;/span&gt;
                    &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;ChoiceType&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="s1"&gt;'options'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                        &lt;span class="s1"&gt;'choices'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                            &lt;span class="s1"&gt;'Published'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'published'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                            &lt;span class="s1"&gt;'Draft'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'draft'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="p"&gt;],&lt;/span&gt;
                    &lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BulkEditField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Product&lt;/span&gt; &lt;span class="nv"&gt;$product&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;$status&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;$product&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                    &lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Product&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;isGranted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'EDIT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nv"&gt;$urls&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&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;setAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Action&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;INDEX&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;unset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'bulk_selection'&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;generateUrl&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are several intentionally explicit decisions in this example.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trusted entity class comes from &lt;code&gt;AdminContext&lt;/code&gt;.&lt;/strong&gt; The initial batch POST includes an entity class name, but client-supplied class names must not decide which repository is modified.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The form definition and its field handler are both defined by the application.&lt;/strong&gt; The bundle does not derive method names from HTTP field names, and checks that configured form fields match their allowed handlers before proceeding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authorization happens at two levels.&lt;/strong&gt; The controller checks access to each action. The callback checks each entity during execution. Your own EasyAdmin permissions and voters must still be configured correctly. Checking permissions only when the review screen is first opened would be insufficient—roles and record ownership can change before confirmation.&lt;/p&gt;

&lt;p&gt;For the complete integration details, see the &lt;a href="https://github.com/alkinbg/NovoraEasyAdminBulkEditBundle/blob/v1.1.3/docs/integration.md" rel="noopener noreferrer"&gt;v1.1.3 integration guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small UX detail that matters
&lt;/h2&gt;

&lt;p&gt;The first iteration taught me that a correct workflow can still feel awkward. EasyAdmin may show its standard batch-action confirmation dialog before the administrator even sees the edit form. Since this bundle already has a separate review and final confirmation, that extra dialog makes the experience confusing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;BulkEditAction::new()&lt;/code&gt; skips that preliminary dialog &lt;strong&gt;only for bulk editing&lt;/strong&gt;, while leaving other batch actions' confirmations intact. I deliberately avoided disabling confirmation for the entire CRUD controller: a different batch action may delete data or perform another irreversible operation.&lt;/p&gt;

&lt;p&gt;The edit screen also separates two decisions: &lt;em&gt;should this field be changed?&lt;/em&gt; and &lt;em&gt;what should its new value be?&lt;/em&gt; For example, updating an &lt;code&gt;active&lt;/code&gt; boolean to &lt;code&gt;false&lt;/code&gt; must not be confused with leaving the field unchanged. The current interface has English and Bulgarian translations, and the review shows boolean values as readable Yes/No equivalents. After a successful commit, it creates a localized Symfony success flash message; how it appears depends on the host application's EasyAdmin layout.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens between selection and confirmation?
&lt;/h2&gt;

&lt;p&gt;The interaction is deliberately split into stages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EasyAdmin index
    |
    | select rows + invoke batch action
    v
Validate action, entity class and batch CSRF
    |
    v
Store selected IDs in the server-side session
    |
    v
Edit form: check fields and enter new values
    |
    | Symfony Form validation
    v
Review page: show selected count and proposed values
    |
    | separate CSRF-protected confirmation
    v
Recheck entity permissions, update through Doctrine, commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server-side selection is associated with a random token, expires after &lt;strong&gt;15 minutes&lt;/strong&gt;, and is consumed when confirmation is attempted. The review values are also stored server-side; the confirmation form does not need to trust a new list of entity identifiers or mutable hidden form fields.&lt;/p&gt;

&lt;p&gt;The review page is not a preview of every entity's old and new values, nor does it execute domain code speculatively. It displays the &lt;em&gt;requested field/value changes&lt;/em&gt; and the number of selected records. Calling that a full “dry run” would be misleading, and the review does not detect changes made by another administrator in the meantime.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why use Doctrine entities instead of one DQL UPDATE?
&lt;/h2&gt;

&lt;p&gt;A single database-level &lt;code&gt;UPDATE&lt;/code&gt; can be tempting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'published'&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(...);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But a raw update is not equivalent to loading entities and calling their methods. In an application that relies on entity methods, Doctrine change tracking, and ORM lifecycle behavior, skipping the normal entity path may change application semantics.&lt;/p&gt;

&lt;p&gt;The bundle instead loads the selected objects, authorizes each one, calls the explicitly configured field handlers, and flushes through Doctrine ORM in a transaction.&lt;/p&gt;

&lt;p&gt;There is a trade-off: that is more work than a single SQL statement. Capping the operation at 100 selected IDs keeps the first version's expectations reasonably clear. Database transactions also have limits: rolling back the database will not undo an email, an HTTP request, or another external side effect triggered during the operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about concurrent changes?
&lt;/h2&gt;

&lt;p&gt;This deserves an explicit answer, particularly for administrative interfaces.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A transaction does not automatically prevent lost updates.&lt;/strong&gt; Imagine someone edits a product after the bulk edit selection was made but before confirmation. Whether that conflict matters—and how it should be resolved—depends on the application. For critical data, consider optimistic versioning, appropriate row locks, or other domain-specific concurrency controls.&lt;/p&gt;

&lt;p&gt;Similarly, &lt;strong&gt;a one-use token in a Symfony session is not a distributed exactly-once guarantee&lt;/strong&gt;. Some session handlers serialize requests for a session; others may behave differently depending on configuration, infrastructure and deployment. If a bulk action causes financial, irreversible or externally visible side effects, use a shared idempotency or locking mechanism designed for that requirement.&lt;/p&gt;

&lt;p&gt;The bundle documents these boundaries instead of presenting CSRF tokens and session state as universal concurrency protection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing the behavior, not just the helpers
&lt;/h2&gt;

&lt;p&gt;I found the most useful tests were the ones that exercised the real request flow, rather than only testing small service methods in isolation.&lt;/p&gt;

&lt;p&gt;The project includes a Symfony integration fixture with EasyAdmin routing, Twig, forms, Doctrine, and SQLite. It checks that the batch action reaches the correct controller, the edit form renders, invalid form data does not write to the database, the review step does not write anything, and the final confirmation performs the update.&lt;/p&gt;

&lt;p&gt;The tests also cover invalid or mismatched batch selections, forged CSRF tokens, attempts to repeat a consumed confirmation, and an access-denial scenario after the review step.&lt;/p&gt;

&lt;p&gt;For &lt;strong&gt;v1.1.3&lt;/strong&gt;, the project passed &lt;strong&gt;27 PHPUnit tests (90 assertions)&lt;/strong&gt; and PHPStan level 8, with CI jobs for PHP 8.4 (minimum and latest permitted dependencies) and PHP 8.5 (latest permitted dependencies). Those checks are useful evidence of behavior—but they are not a substitute for testing your own policies and production database configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons from making it reusable
&lt;/h2&gt;

&lt;p&gt;A reusable Symfony bundle does not have to make every decision for the application. In fact, I think it is healthier when it does not.&lt;/p&gt;

&lt;p&gt;For this project, I wanted the library to own the repeatable infrastructure—selection handoff, edit/review screens, form validation, and transactional orchestration—while the application retains control of entity permissions, allowed fields, setters, and domain rules.&lt;/p&gt;

&lt;p&gt;The scope remains modest. It will not replace every custom administrative workflow, and I do not think it should. I hope it saves someone from implementing the same careful multi-record edit flow repeatedly.&lt;/p&gt;

&lt;p&gt;If you work with EasyAdmin and have encountered a similar case, I would be interested to hear where this approach fits—and where it does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Links&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/alkinbg/NovoraEasyAdminBulkEditBundle" rel="noopener noreferrer"&gt;GitHub repository&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/alkinbg/NovoraEasyAdminBulkEditBundle/releases/tag/v1.1.3" rel="noopener noreferrer"&gt;Release v1.1.3&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://packagist.org/packages/novora/easyadmin-bulk-edit-bundle" rel="noopener noreferrer"&gt;Packagist&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/alkinbg/NovoraEasyAdminBulkEditBundle/blob/v1.1.3/docs/integration.md" rel="noopener noreferrer"&gt;Integration guide&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thanks for reading.&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>php</category>
      <category>doctrine</category>
      <category>opensource</category>
    </item>
    <item>
      <title>When Counting Overlapping Bookings Is Not Enough: Capacity-Aware Availability in PHP</title>
      <dc:creator>Alkin Veysal</dc:creator>
      <pubDate>Wed, 21 Oct 2026 10:18:08 +0000</pubDate>
      <link>https://dev.to/alkin/when-counting-overlapping-bookings-is-not-enough-capacity-aware-availability-in-php-3pam</link>
      <guid>https://dev.to/alkin/when-counting-overlapping-bookings-is-not-enough-capacity-aware-availability-in-php-3pam</guid>
      <description>&lt;p&gt;Imagine you're building a scheduling feature for a loading dock, a shared workspace, or a storage area.&lt;/p&gt;

&lt;p&gt;The resource has a maximum capacity. A reservation occupies some of it for a period of time. You need to answer a deceptively simple question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Can I reserve &lt;strong&gt;2 units for 45 minutes&lt;/strong&gt;, and if so, when can I start?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;At first, this looks like a database query that counts overlapping reservations. I initially thought of it in similar terms. But once capacity changes throughout the day and bookings consume different amounts, counting overlaps no longer gives the right answer.&lt;/p&gt;

&lt;p&gt;That was the starting point for &lt;a href="https://github.com/alkinbg/CapacityAvailability" rel="noopener noreferrer"&gt;CapacityAvailability&lt;/a&gt;, a small PHP library I recently released as version 1.0.0.&lt;/p&gt;

&lt;p&gt;This article is about the problem and the reasoning behind the solution—not an argument that every scheduling application needs another dependency.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: overlap is not concurrency
&lt;/h2&gt;

&lt;p&gt;Suppose a resource has capacity &lt;strong&gt;3&lt;/strong&gt; and already has these bookings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Booking&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Units&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;09:00–09:30&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;09:30–10:00&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;10:00–10:30&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A proposed reservation would use &lt;strong&gt;2 units&lt;/strong&gt; from &lt;strong&gt;09:00 to 10:30&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;All three existing bookings intersect that proposed interval. A naive approach might sum their units, get 3, and reject the request.&lt;/p&gt;

&lt;p&gt;But the existing bookings never run simultaneously. At every instant, only one unit is occupied. Two units remain free throughout the entire 90-minute interval. The request fits.&lt;/p&gt;

&lt;p&gt;Now add another booking for 2 units from 09:45 to 10:15. The answer changes: during part of the interval, all 3 units are occupied.&lt;/p&gt;

&lt;p&gt;What matters is &lt;strong&gt;usage at each point in time&lt;/strong&gt;, not how many reservations touch the requested interval.&lt;/p&gt;

&lt;p&gt;And there's another complication: the resource itself might have capacity 2 before 10:00 and capacity 3 afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model time explicitly
&lt;/h2&gt;

&lt;p&gt;I chose a small model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;capacity interval&lt;/strong&gt; declares the total available units during &lt;code&gt;[start, end)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;reservation&lt;/strong&gt; consumes a positive number of units during &lt;code&gt;[start, end)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A query specifies the required units and the duration for which those units must remain available.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;[start, end)&lt;/code&gt; notation matters. The start is inclusive; the end is exclusive. A booking ending exactly at 10:00 does not conflict with another beginning at 10:00.&lt;/p&gt;

&lt;p&gt;Capacity declarations are &lt;strong&gt;absolute&lt;/strong&gt;, not additive: if a pool has capacity 3, that's a level of 3, not another 3 units to add to an overlapping declaration. Overlapping capacity declarations are rejected. Any period without declared capacity has capacity zero.&lt;/p&gt;

&lt;p&gt;This model also makes a deliberate trade-off: the units are interchangeable. It answers &lt;em&gt;whether&lt;/em&gt; 2 places are available, not &lt;em&gt;which exact two places&lt;/em&gt; to allocate. Scheduling individual machines with different capabilities is a different problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sweep-line: look only where the state changes
&lt;/h2&gt;

&lt;p&gt;The underlying approach is a &lt;a href="https://en.wikipedia.org/wiki/Sweep_line_algorithm" rel="noopener noreferrer"&gt;sweep-line algorithm&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Instead of examining every second or minute, we collect the moments at which something changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;When capacity begins, add its units.&lt;/li&gt;
&lt;li&gt;When capacity ends, subtract its units.&lt;/li&gt;
&lt;li&gt;When a reservation begins, increase occupied units.&lt;/li&gt;
&lt;li&gt;When a reservation ends, decrease occupied units.&lt;/li&gt;
&lt;li&gt;Sort the event times and apply all changes at the same instant together.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Between two consecutive event times, capacity and usage are constant.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Period&lt;/th&gt;
&lt;th&gt;Capacity&lt;/th&gt;
&lt;th&gt;Occupied&lt;/th&gt;
&lt;th&gt;Free&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;09:00–09:30&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;09:30–10:00&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10:00–10:30&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10:30–11:00&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If we need 2 units for 45 minutes, the period from 09:00 to 09:30 is too short. The contiguous availability from 10:00 to 11:00 is long enough.&lt;/p&gt;

&lt;p&gt;The result is not a predefined array of quarter-hour slots. It's a &lt;strong&gt;continuous window&lt;/strong&gt; with enough remaining capacity for the entire requested duration.&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;C&lt;/code&gt; capacity intervals and &lt;code&gt;R&lt;/code&gt; reservations, the calculation uses &lt;code&gt;O((C + R) log(C + R))&lt;/code&gt; time and &lt;code&gt;O(C + R)&lt;/code&gt; additional memory, dominated by sorting the event boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trying it in PHP
&lt;/h2&gt;

&lt;p&gt;Install the package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require alkin/capacity-availability:^1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the same example using the public API:&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="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Alkin\CapacityAvailability\AvailabilityCalculator&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Alkin\CapacityAvailability\CapacityInterval&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Alkin\CapacityAvailability\Reservation&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;fn&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;$time&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;DateTimeImmutable&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DateTimeImmutable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"2026-10-22 &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$time&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:00+00:00"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$capacity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CapacityInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'09:00'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'10:00'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CapacityInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'10:00'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'11:00'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="nv"&gt;$reservations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Reservation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'09:30'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'10:30'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

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

&lt;span class="nv"&gt;$windows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$calculator&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;availableWindows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;capacity&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$capacity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;reservations&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$reservations&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'09:00'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;until&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'11:00'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;requiredUnits&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;durationSeconds&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;45&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$windows&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$window&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s2"&gt;"%s–%s; latest start: %s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$window&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'H:i'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nv"&gt;$window&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'H:i'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nv"&gt;$window&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;latestStart&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'H:i'&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;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10:00–11:00; latest start: 10:15
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last field is worth highlighting: &lt;strong&gt;10:15 is a valid starting time&lt;/strong&gt;. A 45-minute booking starting at 10:15 ends at 11:00, exactly at the exclusive boundary.&lt;/p&gt;

&lt;p&gt;If your UI only offers 15-minute slots, it can choose to display 10:00 and 10:15. But the calculation itself shouldn't impose that grid.&lt;/p&gt;

&lt;p&gt;The library also exposes &lt;code&gt;canBook()&lt;/code&gt; for an exact interval and &lt;code&gt;timeline()&lt;/code&gt; for inspecting capacity, occupancy, and free units.&lt;/p&gt;

&lt;h2&gt;
  
  
  The important distinction: available is not reserved
&lt;/h2&gt;

&lt;p&gt;So far, we've solved a deterministic calculation. We have &lt;strong&gt;not&lt;/strong&gt; solved concurrent booking.&lt;/p&gt;

&lt;p&gt;Imagine two requests arriving at the same time. Each reads the database and sees two free places. Each wants two places. Both call &lt;code&gt;canBook()&lt;/code&gt;, both get &lt;code&gt;true&lt;/code&gt;, and both insert their booking.&lt;/p&gt;

&lt;p&gt;The calculation can be completely correct while the final database state is overbooked.&lt;/p&gt;

&lt;p&gt;That's why the library separates the pure algorithm from an &lt;strong&gt;optional Doctrine DBAL adapter&lt;/strong&gt; for a particular MariaDB/InnoDB schema.&lt;/p&gt;

&lt;p&gt;The adapter's booking path is, in simplified terms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BEGIN TRANSACTION
    SELECT pool_id
    FROM capacity_pools
    WHERE pool_id = :pool
    FOR UPDATE

    -- Read capacity and overlapping bookings
    -- Recalculate availability while holding the pool lock
    -- Insert only when the request fits
COMMIT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The stable pool row acts as the synchronization point. Independent writers for the same pool must acquire that lock &lt;strong&gt;before&lt;/strong&gt; reading the snapshot used for a write decision.&lt;/p&gt;

&lt;p&gt;This comes with a serious condition: &lt;strong&gt;every code path that changes capacity or reservations for that pool must follow the same locking protocol&lt;/strong&gt;. A cancellation, reschedule, import, or manual SQL update that bypasses it can invalidate the guarantee.&lt;/p&gt;

&lt;p&gt;The adapter also intentionally does not provide idempotency, automatic deadlock retries, or arbitrary ORM mappings. It rejects nested transactions; its behavior was verified for the documented MariaDB setup, not every database engine or isolation configuration.&lt;/p&gt;

&lt;p&gt;I would rather document these boundaries clearly than hide them behind a convenient-looking API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing two different kinds of correctness
&lt;/h2&gt;

&lt;p&gt;There were two separate questions to test:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the algorithm giving the correct answer?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Alongside ordinary unit tests, I used an independent reference implementation that samples every minute—or every second for smaller calendars—and compares all possible starts against the sweep-line result.&lt;/p&gt;

&lt;p&gt;The release was checked using &lt;strong&gt;300 randomized minute-resolution calendars&lt;/strong&gt;, &lt;strong&gt;80 second-resolution calendars&lt;/strong&gt;, and &lt;strong&gt;77,525 reference assertions&lt;/strong&gt;, plus unit tests and static analysis.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the database adapter prevent the race?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This needed actual concurrent processes, not two sequential method calls.&lt;/p&gt;

&lt;p&gt;In an isolated MariaDB 11.3.2 test database, I ran scenarios with independent PHP workers. The suite includes an intentionally unsafe example that demonstrates overbooking, followed by properly locked transactions.&lt;/p&gt;

&lt;p&gt;The recorded result was &lt;strong&gt;12 scenarios, 43 independent PHP processes, and 198 assertions&lt;/strong&gt;. The unsafe test is deliberately confined to disposable test data.&lt;/p&gt;

&lt;p&gt;Those tests are useful evidence for the documented design; they are not a promise that every application integration is automatically safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this isn't a Symfony bundle
&lt;/h2&gt;

&lt;p&gt;I work primarily with Symfony, and the early experiments included a real Symfony application using DoctrineBundle. It was tempting to package everything as a Symfony bundle.&lt;/p&gt;

&lt;p&gt;But the calculation itself doesn't need Symfony. It just takes arrays of immutable value objects and returns results.&lt;/p&gt;

&lt;p&gt;In a Symfony application, the calculator can be registered as a normal service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/services.yaml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;Alkin\CapacityAvailability\AvailabilityCalculator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;~&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was enough to integrate the core using dependency injection. The optional Doctrine classes can be registered similarly when the application uses the documented persistence schema.&lt;/p&gt;

&lt;p&gt;A bundle may make sense one day if it provides meaningful integration behavior. Adding one merely to wrap a service registration would increase maintenance without solving another problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stops
&lt;/h2&gt;

&lt;p&gt;CapacityAvailability is not a complete booking platform.&lt;/p&gt;

&lt;p&gt;It does not handle recurring calendars, holidays, payments, user permissions, named individual resources, or multi-pool optimization. It also doesn't magically adapt its database writer to an existing application's schema.&lt;/p&gt;

&lt;p&gt;What it does offer is a focused solution to one recurring question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Given changing capacity and overlapping usage, when does a request fit for its &lt;strong&gt;entire duration&lt;/strong&gt;?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Keeping that boundary explicit is part of the design.&lt;/p&gt;

&lt;p&gt;The project is open source under MIT, supports PHP 8.2+, and the calculation core has no required runtime dependencies.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Source and documentation:&lt;/strong&gt; &lt;a href="https://github.com/alkinbg/CapacityAvailability" rel="noopener noreferrer"&gt;GitHub — CapacityAvailability&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Installation:&lt;/strong&gt; &lt;a href="https://packagist.org/packages/alkin/capacity-availability" rel="noopener noreferrer"&gt;Packagist — alkin/capacity-availability&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrency details:&lt;/strong&gt; &lt;a href="https://github.com/alkinbg/CapacityAvailability/blob/main/docs/TRANSACTIONS.md" rel="noopener noreferrer"&gt;Transactional booking contract&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've solved a similar scheduling or capacity problem, I'd be interested in the edge cases you ran into—especially where the simple interchangeable-capacity model stops being sufficient.&lt;/p&gt;

</description>
      <category>php</category>
      <category>algorithms</category>
      <category>symfony</category>
      <category>doctrine</category>
    </item>
    <item>
      <title>Unsaved Form Changes in Symfony — With and Without Turbo</title>
      <dc:creator>Alkin Veysal</dc:creator>
      <pubDate>Mon, 19 Oct 2026 20:19:30 +0000</pubDate>
      <link>https://dev.to/alkin/unsaved-form-changes-in-symfony-with-and-without-turbo-2ob5</link>
      <guid>https://dev.to/alkin/unsaved-form-changes-in-symfony-with-and-without-turbo-2ob5</guid>
      <description>&lt;p&gt;Not every form needs autosave. Sometimes the most useful thing an application can do is ask a simple question before someone navigates away:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You have unsaved changes. Leave this page?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It's a small detail, but a helpful one on editing screens: account settings, product details, admin forms, or anything that may take a few minutes to fill out.&lt;/p&gt;

&lt;p&gt;I wanted a reusable way to add that behavior to a Symfony form without creating a new form type, adding server-side infrastructure, or writing another one-off JavaScript controller for each project.&lt;/p&gt;

&lt;p&gt;That became &lt;a href="https://github.com/alkinbg/UnsavedFormBundle" rel="noopener noreferrer"&gt;Unsaved Form Bundle&lt;/a&gt;, a small open-source Symfony UX bundle built around one Stimulus controller. Version &lt;strong&gt;1.0.0&lt;/strong&gt; is available on &lt;a href="https://packagist.org/packages/alkin/unsaved-form-bundle" rel="noopener noreferrer"&gt;Packagist&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This post is about the convenience itself, how to use it, and some less obvious browser and Turbo behaviors worth understanding before relying on any unsaved-changes warning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;beforeunload&lt;/code&gt; alone is not quite enough
&lt;/h2&gt;

&lt;p&gt;For a traditional page, the starting point is familiar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;beforeunload&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;hasUnsavedChanges&lt;/span&gt;&lt;span class="p"&gt;)&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="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;returnValue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&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 illustrates the native browser mechanism, but it isn't a complete implementation. We still need to determine whether a form really changed, stop warning after a successful save, and avoid warnings when an ordinary submission is already in progress.&lt;/p&gt;

&lt;p&gt;And once Turbo is involved, some navigations replace content without unloading the document at all.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Turbo Drive&lt;/strong&gt; can visit a different page without a full browser reload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turbo Back/Forward&lt;/strong&gt; restoration doesn't fire &lt;code&gt;turbo:before-visit&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turbo Frames&lt;/strong&gt; can replace only the part of the page containing the form.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turbo Streams&lt;/strong&gt; can replace or remove the form without changing the URL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are separate events in &lt;a href="https://turbo.hotwired.dev/reference/events" rel="noopener noreferrer"&gt;Turbo's documented lifecycle&lt;/a&gt;. A guard that listens only to &lt;code&gt;beforeunload&lt;/code&gt; can miss them.&lt;/p&gt;

&lt;p&gt;The goal wasn't to build a form framework. It was to make this small piece of behavior reusable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add it to a Symfony form
&lt;/h2&gt;

&lt;p&gt;The package requires &lt;strong&gt;PHP 8.2+&lt;/strong&gt;, &lt;strong&gt;Symfony 7.4 or 8.x&lt;/strong&gt;, and a supported &lt;strong&gt;Symfony StimulusBundle&lt;/strong&gt; integration. It works in applications with or without Symfony UX Turbo.&lt;/p&gt;

&lt;p&gt;Install the stable 1.x release:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require &lt;span class="s2"&gt;"alkin/unsaved-form-bundle:^1.0"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a standard Symfony UX setup, Symfony Flex registers the bundle and the Stimulus controller. It's worth checking &lt;code&gt;config/bundles.php&lt;/code&gt;, &lt;code&gt;assets/controllers.json&lt;/code&gt;, and the application's frontend configuration after installation.&lt;/p&gt;

&lt;p&gt;Then add a controller to the &lt;strong&gt;form element&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight twig"&gt;&lt;code&gt;&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;form_start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;attr&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt;
        &lt;span class="s1"&gt;'data-controller'&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'alkin--unsaved-form-bundle--guard'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'data-alkin--unsaved-form-bundle--guard-message-value'&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
            &lt;span class="s1"&gt;'You have unsaved changes. Leave this page?'&lt;/span&gt;
    &lt;span class="err"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
    &lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;form_widget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;form&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt; &lt;span class="na"&gt;aria-live=&lt;/span&gt;&lt;span class="s"&gt;"polite"&lt;/span&gt; &lt;span class="na"&gt;hidden&lt;/span&gt;
          &lt;span class="na"&gt;data-alkin--unsaved-form-bundle--guard-target=&lt;/span&gt;&lt;span class="s"&gt;"indicator"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        Unsaved changes
    &lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Save&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;form_end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;form&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the basic integration. The message and indicator are optional; the form does not need an extra controller action or a database table.&lt;/p&gt;

&lt;p&gt;The optional indicator becomes visible while the form has unsaved changes. As with other dynamic UI messages, check that its accessibility behavior fits your own form and assistive-technology setup.&lt;/p&gt;

&lt;h3&gt;
  
  
  It is not limited to Symfony Forms
&lt;/h3&gt;

&lt;p&gt;The controller attaches to a regular HTML &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; element too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;data-controller=&lt;/span&gt;&lt;span class="s"&gt;"alkin--unsaved-form-bundle--guard"&lt;/span&gt; &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"post"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;label&amp;gt;&lt;/span&gt;
        Title
        &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"title"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"Example"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Save&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You still need StimulusBundle and the frontend assets to be loaded. Turbo is optional.&lt;/p&gt;

&lt;h2&gt;
  
  
  A change is not the same as a touched field
&lt;/h2&gt;

&lt;p&gt;An important design choice is to compare a form's current values with its starting values rather than remember whether an &lt;code&gt;input&lt;/code&gt; event happened at some point.&lt;/p&gt;

&lt;p&gt;Consider this simple sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A field starts with &lt;code&gt;Draft&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Someone edits it to &lt;code&gt;Published&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;They change it back to &lt;code&gt;Draft&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The form should be considered clean again.&lt;/p&gt;

&lt;p&gt;The controller takes a snapshot of relevant form values and compares against that baseline. It accounts for ordinary text fields, checkboxes, radio buttons, selects, textareas, and selected files. It also reacts to resets and detects changes made programmatically when it checks the form before navigation.&lt;/p&gt;

&lt;p&gt;Sometimes a value should not affect the warning, such as a purely client-side helper field. It can be excluded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"temporary-ui-state"&lt;/span&gt; &lt;span class="na"&gt;data-unsaved-form-ignore&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The attribute can also be placed on a wrapper around fields that should be ignored.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens with Turbo?
&lt;/h2&gt;

&lt;p&gt;For normal Turbo Drive visits, the controller can ask for confirmation before the visit begins. For native browser navigation, it relies on the browser's &lt;code&gt;beforeunload&lt;/code&gt; dialog.&lt;/p&gt;

&lt;p&gt;History restoration needs different handling. Turbo explicitly does &lt;strong&gt;not&lt;/strong&gt; emit &lt;code&gt;turbo:before-visit&lt;/code&gt; for Back/Forward navigation, so that event alone cannot protect an edited form. The bundle also uses browser history information where available and keeps the original form state in page memory so a cached Turbo render does not simply turn an unsaved form into a new, apparently clean one.&lt;/p&gt;

&lt;p&gt;Frames and Streams are another case: they can remove or replace an edited form &lt;em&gt;without leaving the page&lt;/em&gt;. The bundle listens to their render lifecycle and asks for confirmation when the pending update would affect that form. Unrelated frame or stream updates can continue.&lt;/p&gt;

&lt;p&gt;There's one more detail that's easy to overlook: &lt;strong&gt;validation failures&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A Symfony form submitted through Turbo may return an HTTP &lt;strong&gt;422&lt;/strong&gt; response containing validation errors and re-rendered form markup. That is not a successful save. The new form should still be treated as containing unsaved edits, rather than resetting its baseline just because new HTML appeared.&lt;/p&gt;

&lt;p&gt;These behaviors are why the controller grew beyond a short &lt;code&gt;beforeunload&lt;/code&gt; snippet. They are also why I would not judge a navigation guard only by a couple of happy-path unit tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Submission and custom JavaScript
&lt;/h2&gt;

&lt;p&gt;A regular form submission should not trigger an unnecessary leave-page warning simply because the browser is submitting the form.&lt;/p&gt;

&lt;p&gt;For Turbo submissions, the controller follows Turbo's submission lifecycle. A successful response can mark the current state clean; an unsuccessful one should keep the unsaved state. For an application that saves via custom AJAX code outside that lifecycle, the controller exposes a small method, &lt;strong&gt;&lt;code&gt;markClean()&lt;/code&gt;&lt;/strong&gt;, which the application can call after the server has confirmed a successful save.&lt;/p&gt;

&lt;p&gt;This is deliberate: the bundle does &lt;strong&gt;not&lt;/strong&gt; guess that a click on a Save button means data was persisted.&lt;/p&gt;

&lt;p&gt;It also emits an &lt;code&gt;alkin--unsaved-form-bundle--guard:change&lt;/code&gt; event with &lt;code&gt;event.detail.dirty&lt;/code&gt; if you want to update other UI elements when the dirty state changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does &lt;em&gt;not&lt;/em&gt; promise
&lt;/h2&gt;

&lt;p&gt;It's important to keep the scope clear.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is a navigation warning, not a draft storage system.&lt;/strong&gt; It does not store edits in a database or &lt;code&gt;localStorage&lt;/code&gt;, automatically save to the server, or guarantee recovery after a tab or browser process disappears. The temporary state used during Turbo restoration lives in the page's JavaScript memory.&lt;/p&gt;

&lt;p&gt;Browsers also control native unload dialogs. They may require prior user interaction, they choose the dialog text, and on some mobile or OS-initiated termination paths they may not show a prompt at all. This is a browser constraint, not something a Symfony bundle can override. &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event" rel="noopener noreferrer"&gt;MDN's &lt;code&gt;beforeunload&lt;/code&gt; documentation&lt;/a&gt; explains the limitations.&lt;/p&gt;

&lt;p&gt;Similarly, the history behavior depends on modern browser APIs being available. And if unrelated application code deliberately destroys a form outside the supported navigation and Turbo lifecycle, that code needs to coordinate with the guard.&lt;/p&gt;

&lt;p&gt;Those boundaries matter more than a claim that it can "never lose changes." If an application needs guaranteed draft recovery, it should implement actual persistence or autosave separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing beyond a single browser
&lt;/h2&gt;

&lt;p&gt;For the &lt;strong&gt;v1.0.0&lt;/strong&gt; release, the project includes &lt;strong&gt;29 JavaScript unit tests&lt;/strong&gt; and real-browser navigation scenarios using Playwright and Turbo 8. Its CI checks &lt;strong&gt;Chromium, Firefox, and WebKit&lt;/strong&gt;, alongside PHP 8.2/8.4 checks.&lt;/p&gt;

&lt;p&gt;The browser scenarios exercise ordinary and Turbo navigation, rejected and approved Back/Forward visits, Frames, destructive Streams, form submission, and an HTTP 422 validation response. The file-input scenario checks that declining a history navigation doesn't discard a selected file in the tested browser behavior.&lt;/p&gt;

&lt;p&gt;That doesn't prove every browser version or integration is perfect. It does make regressions in those specific paths easier to detect.&lt;/p&gt;

&lt;p&gt;If you're evaluating this for your own project, I would still test it against the way your application uses Turbo, especially if you have custom morphing, unusual form markup, or JavaScript that replaces parts of the DOM.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small convenience, not another form framework
&lt;/h2&gt;

&lt;p&gt;Symfony already gives us excellent form, validation, and frontend tools. I didn't want another layer around them just to display one warning.&lt;/p&gt;

&lt;p&gt;Unsaved Form Bundle is meant to stay narrow: &lt;strong&gt;one opt-in Stimulus controller for a behavior that can be useful in many editing screens&lt;/strong&gt;. It won't suit every application, and it doesn't need to.&lt;/p&gt;

&lt;p&gt;The source, usage notes, limitations, and tests are available on &lt;a href="https://github.com/alkinbg/UnsavedFormBundle" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. The package is on &lt;a href="https://packagist.org/packages/alkin/unsaved-form-bundle" rel="noopener noreferrer"&gt;Packagist&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you try it and run into a reproducible edge case, a small example and the browser/Turbo versions would be especially helpful. That's often more useful than a long feature wishlist.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Cover photo: &lt;a href="https://www.pexels.com/photo/close-up-of-programmer-typing-code-on-laptop-36496927/" rel="noopener noreferrer"&gt;Jakub Zerdzicki on Pexels&lt;/a&gt;, used under the &lt;a href="https://www.pexels.com/license/" rel="noopener noreferrer"&gt;Pexels License&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>php</category>
      <category>javascript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>One Symfony Form, Two Views: Read-Only Summaries Without Duplication</title>
      <dc:creator>Alkin Veysal</dc:creator>
      <pubDate>Wed, 14 Oct 2026 12:21:19 +0000</pubDate>
      <link>https://dev.to/alkin/one-symfony-form-two-views-read-only-summaries-without-duplication-1458</link>
      <guid>https://dev.to/alkin/one-symfony-form-two-views-read-only-summaries-without-duplication-1458</guid>
      <description>&lt;p&gt;A form often has a second life.&lt;/p&gt;

&lt;p&gt;First, someone uses it to enter or update information. Then, somewhere else in the application, someone needs to &lt;em&gt;read&lt;/em&gt; that same information: on a confirmation screen, an internal detail page, or a review step before submitting a request.&lt;/p&gt;

&lt;p&gt;The editable form is already there. Yet the read-only version frequently becomes another Twig template, with another list of fields, another set of labels, and sometimes another set of rules for displaying their values.&lt;/p&gt;

&lt;p&gt;This is not a serious architectural failure. Writing a dedicated template is perfectly reasonable. But when an application contains many structured forms, a small amount of repeated presentation code can become a surprisingly persistent maintenance task.&lt;/p&gt;

&lt;p&gt;I wanted to explore a modest alternative: &lt;strong&gt;what if a read-only view could reuse the information we have already given to Symfony's Form component?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That question led to &lt;a href="https://github.com/alkinbg/FormSummaryBundle" rel="noopener noreferrer"&gt;FormSummaryBundle&lt;/a&gt;, a small open-source bundle with a deliberately limited purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The duplication is easy to miss
&lt;/h2&gt;

&lt;p&gt;Consider a delivery form with a carrier, status, pallet count, and a confirmation checkbox. The status field might be defined like 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;$builder&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ChoiceType&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="s1"&gt;'label'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'choices'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'Scheduled'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'scheduled'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'Ready to load'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'ready'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The stored value is &lt;code&gt;ready&lt;/code&gt;, but the label shown to the user is &lt;strong&gt;Ready to load&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Now imagine a separate read-only Twig template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight twig"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;dl&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;dt&amp;gt;&lt;/span&gt;Carrier&lt;span class="nt"&gt;&amp;lt;/dt&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;dd&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;delivery.carrier&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/dd&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;dt&amp;gt;&lt;/span&gt;Status&lt;span class="nt"&gt;&amp;lt;/dt&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;dd&amp;gt;&lt;/span&gt;
        &lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nv"&gt;delivery.status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s1"&gt;'ready'&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
            Ready to load
        &lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
            Scheduled
        &lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;endif&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/dd&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;dt&amp;gt;&lt;/span&gt;Pallets&lt;span class="nt"&gt;&amp;lt;/dt&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;dd&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;delivery.pallets&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/dd&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dl&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is nothing inherently wrong with this template. The problem is that part of the form's presentation contract now exists in two places.&lt;/p&gt;

&lt;p&gt;If the status labels change, someone has to remember to update the detail view. If the form gains a field, the read-only version might need a matching change. If the application supports multiple languages, the two views must agree on translation domains and parameters. Formatting an empty value, a date, or a monetary amount introduces similar decisions.&lt;/p&gt;

&lt;p&gt;This matters less for a single form. It matters more when you repeat the pattern across many forms and screens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symfony already gives us most of the ingredients
&lt;/h2&gt;

&lt;p&gt;Symfony Forms are not simply a collection of HTML inputs. They hold information about field types, labels, choices, translation settings, and transformations between application data and values used for display.&lt;/p&gt;

&lt;p&gt;When we call &lt;code&gt;createView()&lt;/code&gt;, Symfony produces a &lt;code&gt;FormView&lt;/code&gt; containing the information its rendering layer needs. And Symfony already exposes helpers such as &lt;a href="https://symfony.com/doc/current/form/form_customization.html" rel="noopener noreferrer"&gt;&lt;code&gt;field_label()&lt;/code&gt; and &lt;code&gt;field_value()&lt;/code&gt;&lt;/a&gt;, along with an established &lt;a href="https://symfony.com/doc/current/form/form_themes.html" rel="noopener noreferrer"&gt;form-theming system&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That distinction is important. &lt;strong&gt;FormSummaryBundle does not invent a new form model, replace Symfony's rendering system, or claim that this information was previously inaccessible.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It provides a convenience on top of those capabilities: a complete read-only representation, with common field types handled consistently, without manually mapping every field into a second template.&lt;/p&gt;

&lt;p&gt;The entire basic API is one Twig call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight twig"&gt;&lt;code&gt;&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;form_summary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;form&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting part is not the line itself. It is how much existing form information that line can reuse.&lt;/p&gt;

&lt;h2&gt;
  
  
  A complete example
&lt;/h2&gt;

&lt;p&gt;Here is a small form type using only standard Symfony fields.&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="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Form&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Form\AbstractType&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Form\Extension\Core\Type\CheckboxType&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Form\Extension\Core\Type\ChoiceType&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Form\Extension\Core\Type\IntegerType&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Form\Extension\Core\Type\TextType&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Form\FormBuilderInterface&lt;/span&gt;&lt;span class="p"&gt;;&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;DeliveryType&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;AbstractType&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;buildForm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;FormBuilderInterface&lt;/span&gt; &lt;span class="nv"&gt;$builder&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;$options&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;$builder&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'carrier'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TextType&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="s1"&gt;'label'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Carrier'&lt;/span&gt;&lt;span class="p"&gt;,&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;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ChoiceType&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="s1"&gt;'label'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'choices'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                    &lt;span class="s1"&gt;'Scheduled'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'scheduled'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="s1"&gt;'Ready to load'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'ready'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pallets'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;IntegerType&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="s1"&gt;'label'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Pallets'&lt;/span&gt;&lt;span class="p"&gt;,&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;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'confirmed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;CheckboxType&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="s1"&gt;'label'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Confirmed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'required'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="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;For this demonstration, the form is populated with an array. A real application could use its normal entity, DTO, or other data structure instead.&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;$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;DeliveryType&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="s1"&gt;'carrier'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Example Logistics'&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;'ready'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'pallets'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'confirmed'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="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;'delivery/review.html.twig'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'form'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;createView&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 the view is just:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight twig"&gt;&lt;code&gt;&lt;span class="c"&gt;{# templates/delivery/review.html.twig #}&lt;/span&gt;
&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;form_summary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;form&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default output is a semantic HTML description list, conceptually equivalent to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;dl&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"form-summary"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"form-summary__row"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;dt&amp;gt;&lt;/span&gt;Carrier&lt;span class="nt"&gt;&amp;lt;/dt&amp;gt;&amp;lt;dd&amp;gt;&lt;/span&gt;Example Logistics&lt;span class="nt"&gt;&amp;lt;/dd&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"form-summary__row"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;dt&amp;gt;&lt;/span&gt;Status&lt;span class="nt"&gt;&amp;lt;/dt&amp;gt;&amp;lt;dd&amp;gt;&lt;/span&gt;Ready to load&lt;span class="nt"&gt;&amp;lt;/dd&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"form-summary__row"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;dt&amp;gt;&lt;/span&gt;Pallets&lt;span class="nt"&gt;&amp;lt;/dt&amp;gt;&amp;lt;dd&amp;gt;&lt;/span&gt;0&lt;span class="nt"&gt;&amp;lt;/dd&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"form-summary__row"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;dt&amp;gt;&lt;/span&gt;Confirmed&lt;span class="nt"&gt;&amp;lt;/dt&amp;gt;&amp;lt;dd&amp;gt;&lt;/span&gt;No&lt;span class="nt"&gt;&amp;lt;/dd&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dl&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice four things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The choice label is preserved.&lt;/strong&gt; The summary displays &lt;em&gt;Ready to load&lt;/em&gt;, not the underlying &lt;code&gt;ready&lt;/code&gt; value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero stays zero.&lt;/strong&gt; A pallet count of &lt;code&gt;0&lt;/code&gt; is not treated as missing data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;False is meaningful.&lt;/strong&gt; The unchecked checkbox becomes &lt;em&gt;No&lt;/em&gt; in the default English translation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Labels have a single source.&lt;/strong&gt; The summary reuses the labels already configured on the form.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These are small details, but they are exactly the details that tend to diverge when we maintain separate representations by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is more than a loop over fields
&lt;/h2&gt;

&lt;p&gt;A simple Twig loop could handle text fields quite well. Real Symfony forms, however, contain values whose display representation is not always their raw value.&lt;/p&gt;

&lt;h3&gt;
  
  
  Choice fields
&lt;/h3&gt;

&lt;p&gt;A &lt;code&gt;ChoiceType&lt;/code&gt; has its own mapping between submitted values and user-facing labels. A multiple-choice field may contain several selected values, grouped choices, or preferred choices. The summary uses existing choice views rather than inventing another label map.&lt;/p&gt;

&lt;p&gt;This also applies to standard choice-derived types, including enum choices.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dates and times
&lt;/h3&gt;

&lt;p&gt;Symfony can render dates and times as single-text widgets or as compound fields containing individual components. The bundle works with those view representations. It respects the configured date pattern for supported compound dates and preserves the transformed view value for single-text fields.&lt;/p&gt;

&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; promise to localize every date into a new, independently chosen format. That is a responsibility worth keeping explicit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Nested forms and collections
&lt;/h3&gt;

&lt;p&gt;A form can contain a nested form or a collection of fields. The summary follows the existing &lt;code&gt;FormView&lt;/code&gt; children and produces nested description lists. It summarizes the entries already in the form view, not collection prototypes intended for client-side additions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Missing values
&lt;/h3&gt;

&lt;p&gt;By default, an absent text value is displayed as an em dash. The caller can choose another placeholder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight twig"&gt;&lt;code&gt;&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;form_summary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;summary_empty&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Not provided'&lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same placeholder is used for missing components of partial compound dates and times. Values such as &lt;code&gt;0&lt;/code&gt; and &lt;code&gt;false&lt;/code&gt; remain distinct from an empty string or &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The aim is consistency, not magic.&lt;/p&gt;

&lt;h2&gt;
  
  
  You do not have to summarize the whole form
&lt;/h2&gt;

&lt;p&gt;Sometimes a full description list is unnecessary. Perhaps a card only needs one label/value pair, or a sentence needs the human-readable label of a selected choice.&lt;/p&gt;

&lt;p&gt;Two smaller helpers are available:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight twig"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;dl&amp;gt;&lt;/span&gt;
    &lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;form_summary_row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;form.carrier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
    &lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;form_summary_row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;form.status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dl&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Current status: &lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;form_summary_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;form.status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;form_summary_row()&lt;/code&gt; renders one labeled row; &lt;code&gt;form_summary_value()&lt;/code&gt; renders only the value.&lt;/p&gt;

&lt;p&gt;Another useful detail: rendering a summary does not mark the original form view's fields as rendered. An application can still render editable widgets from the same view, before or after showing the summary.&lt;/p&gt;

&lt;p&gt;That makes the approach useful for review panels alongside editable forms, without turning a read-only representation into a set of disabled inputs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two kinds of customization
&lt;/h2&gt;

&lt;p&gt;I wanted customization to remain close to the conventions Symfony developers already know, rather than introduce another configuration language.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Control the value at the form level
&lt;/h3&gt;

&lt;p&gt;For a field whose ordinary view value is not what we want to display, the bundle provides a &lt;code&gt;summary_value&lt;/code&gt; option:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Form\FormView&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$builder&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'reference'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TextType&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="s1"&gt;'summary_value'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;FormView&lt;/span&gt; &lt;span class="nv"&gt;$view&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="s1"&gt;'REF-'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;strtoupper&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$view&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;vars&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'value'&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 callback runs when the value is summarized. It should be deterministic and free of side effects, because rendering the same summary again evaluates it again.&lt;/p&gt;

&lt;p&gt;The default theme treats the result as text and escapes it. A custom scalar value is not a shortcut for injecting raw HTML.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Control the markup with a form theme
&lt;/h3&gt;

&lt;p&gt;Symfony already has a mechanism for overriding how field types are rendered. The bundle adds summary-specific blocks to that mechanism.&lt;/p&gt;

&lt;p&gt;For example, an application can provide a Twig form theme:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight twig"&gt;&lt;code&gt;&lt;span class="c"&gt;{# templates/form/summary.html.twig #}&lt;/span&gt;
&lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;block&lt;/span&gt; &lt;span class="nv"&gt;text_summary_value&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;strong&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;value&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/strong&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;endblock&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And apply it to the view:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight twig"&gt;&lt;code&gt;&lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="nv"&gt;form_theme&lt;/span&gt; &lt;span class="nv"&gt;form&lt;/span&gt; &lt;span class="s1"&gt;'form/summary.html.twig'&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;form_summary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;form&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Summary-specific blocks include &lt;code&gt;form_summary&lt;/code&gt;, &lt;code&gt;form_summary_row&lt;/code&gt;, &lt;code&gt;form_summary_value&lt;/code&gt;, and type-specific variations such as &lt;code&gt;choice_summary_value&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The default theme does not load a stylesheet or replace normal editable widget blocks. A project can style the provided classes or override the markup to fit its design system.&lt;/p&gt;

&lt;p&gt;There is no requirement to adopt Bootstrap, Tailwind, or another UI framework.&lt;/p&gt;

&lt;h2&gt;
  
  
  The security boundary is worth stating clearly
&lt;/h2&gt;

&lt;p&gt;A read-only summary is &lt;strong&gt;not&lt;/strong&gt; an authorization mechanism.&lt;/p&gt;

&lt;p&gt;The bundle omits types such as password, hidden, file, repeated, and button fields. This keeps tokens, file inputs, and other inappropriate controls out of the default summary.&lt;/p&gt;

&lt;p&gt;An application can also exclude a regular field:&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;$builder&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'internalNote'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TextType&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="s1"&gt;'summary'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But a field containing sensitive data may still be an ordinary &lt;code&gt;TextType&lt;/code&gt;. No general-purpose form renderer can determine who is entitled to see that data.&lt;/p&gt;

&lt;p&gt;The application must continue to enforce access control, select the correct data for the user, and decide what should appear on each page. Likewise, displaying a submitted form does not make its data valid: confirmation flows still need the application's normal submission and validation checks.&lt;/p&gt;

&lt;p&gt;The default summary theme escapes text and choice labels. Custom themes remain application code, so developers should preserve escaping and avoid applying &lt;code&gt;raw&lt;/code&gt; to untrusted data.&lt;/p&gt;

&lt;p&gt;It is better to describe this boundary clearly than to promise safety a presentation package cannot provide.&lt;/p&gt;

&lt;h2&gt;
  
  
  When I would use it — and when I would not
&lt;/h2&gt;

&lt;p&gt;I find this abstraction useful when a read-only screen closely follows the structure of an existing form: confirmation pages, internal inspection views, structured administrative details, or review sections in operational software.&lt;/p&gt;

&lt;p&gt;It can also reduce routine maintenance. When a label or a choice changes in the form, the summary continues to use the form's current metadata, rather than a separately maintained mapping.&lt;/p&gt;

&lt;p&gt;But I would &lt;strong&gt;not&lt;/strong&gt; use it automatically on every detail page.&lt;/p&gt;

&lt;p&gt;A dashboard with calculated KPIs, a domain-specific timeline, or a carefully designed customer-facing profile may deserve its own view model and template. A screen that combines data from several sources may not correspond to a form at all. Even for a simple form, five explicit lines of Twig may be more understandable than introducing a dependency.&lt;/p&gt;

&lt;p&gt;The question is not whether the bundle can render the data. The question is whether &lt;strong&gt;the form's existing structure is the right structure for the read-only presentation&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If the answer is yes, reuse makes sense. If the answer is no, a dedicated template is often the cleaner design.&lt;/p&gt;

&lt;p&gt;That trade-off is intentional.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the bundle deliberately does not do
&lt;/h2&gt;

&lt;p&gt;To keep its responsibilities narrow, FormSummaryBundle does not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;validate submissions or enforce authorization;&lt;/li&gt;
&lt;li&gt;query entities to build a separate read model;&lt;/li&gt;
&lt;li&gt;persist or modify data;&lt;/li&gt;
&lt;li&gt;generate PDFs or send emails;&lt;/li&gt;
&lt;li&gt;implement a multi-step workflow; or&lt;/li&gt;
&lt;li&gt;automatically understand every possible custom compound field.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It renders from an already created &lt;code&gt;FormView&lt;/code&gt;. Creating that form view may still involve normal Symfony transformations or choice loading, and a more specialized field can use &lt;code&gt;summary_value&lt;/code&gt; or a custom summary theme block.&lt;/p&gt;

&lt;p&gt;This is a small convenience layer, not another application framework.&lt;/p&gt;

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

&lt;p&gt;The package is published on &lt;a href="https://packagist.org/packages/alkin/form-summary-bundle" rel="noopener noreferrer"&gt;Packagist&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require &lt;span class="s2"&gt;"alkin/form-summary-bundle:^1.0"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your application does not register the bundle automatically, add it to &lt;code&gt;config/bundles.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Alkin\FormSummaryBundle\FormSummaryBundle&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'all'&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;There is no separate summary schema to configure. Create your Symfony form as usual, pass its &lt;code&gt;FormView&lt;/code&gt; to Twig, and use &lt;code&gt;form_summary()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Version &lt;strong&gt;1.0.0&lt;/strong&gt; targets PHP 8.2+ and compatible Symfony 6.4, 7.4, and 8.x applications. The repository's CI matrix covers combinations of PHP 8.2–8.5 and Symfony 6.4, 7.4, 8.0, and 8.1, as well as lower supported dependency versions and installation of the exported package without development dependencies.&lt;/p&gt;

&lt;p&gt;The test suite includes 94 PHPUnit tests with 214 assertions. Among the tested behaviors are escaping, translated choice labels, excluded fields, zero and false values, compound dates, and compatibility with editable form themes.&lt;/p&gt;

&lt;p&gt;Tests cannot guarantee that every custom form behaves identically, but they provide a useful baseline for maintaining a small public API.&lt;/p&gt;

&lt;p&gt;For the full option reference, supported field types, theme overrides, and known limitations, see the &lt;a href="https://github.com/alkinbg/FormSummaryBundle#readme" rel="noopener noreferrer"&gt;README&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A final thought
&lt;/h2&gt;

&lt;p&gt;The most satisfying part of this project was not creating a new rendering system. It was realizing that Symfony already gives us much of what we need.&lt;/p&gt;

&lt;p&gt;There is a place for dedicated, carefully designed templates. There is also a place for a small utility that removes a repeated task without changing how the rest of an application works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When the form already describes the information we want to show, reusing that description can be simpler than writing it all again.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is the entire idea behind FormSummaryBundle.&lt;/p&gt;

&lt;p&gt;The source is available on &lt;a href="https://github.com/alkinbg/FormSummaryBundle" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, and the package is available on &lt;a href="https://packagist.org/packages/alkin/form-summary-bundle" rel="noopener noreferrer"&gt;Packagist&lt;/a&gt;. Feedback is welcome, particularly from developers working with nested forms and custom field types. Those are often the places where a small abstraction reveals both its value and its limits.&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>php</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Applying Kaizen Thinking to Symfony Debugging</title>
      <dc:creator>Alkin Veysal</dc:creator>
      <pubDate>Sat, 10 Oct 2026 07:13:02 +0000</pubDate>
      <link>https://dev.to/alkin/applying-kaizen-thinking-to-symfony-debugging-36pb</link>
      <guid>https://dev.to/alkin/applying-kaizen-thinking-to-symfony-debugging-36pb</guid>
      <description>&lt;p&gt;&lt;strong&gt;A small open-source experiment in understanding recurring log issues, documenting investigations, and reviewing improvements.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Introducing Novora KaizenBundle 1.0 — a small, Lean-inspired approach to investigating recurring application issues.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When working on a Symfony application, logs are often the first place we look when something goes wrong.&lt;/p&gt;

&lt;p&gt;We find an exception, investigate it, make a correction, and move on.&lt;/p&gt;

&lt;p&gt;But sometimes the same issue returns. Or we discover that a particular warning has been appearing for days without anyone noticing how frequently it occurs.&lt;/p&gt;

&lt;p&gt;That made me interested in a simple question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Could we make the process of identifying, investigating, and reviewing recurring problems a little more structured, without introducing another monitoring platform?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This question led to &lt;strong&gt;Novora KaizenBundle&lt;/strong&gt;, a small open-source package for Symfony.&lt;/p&gt;

&lt;p&gt;Version 1.0 is now available on &lt;a href="https://packagist.org/packages/novora/kaizen-bundle" rel="noopener noreferrer"&gt;Packagist&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea behind the bundle
&lt;/h2&gt;

&lt;p&gt;In Lean thinking, &lt;em&gt;Kaizen&lt;/em&gt; describes continuous improvement through observation, analysis, and incremental changes.&lt;/p&gt;

&lt;p&gt;These principles are not new, and neither are the techniques used in this bundle.&lt;/p&gt;

&lt;p&gt;Pareto analysis, Ishikawa diagrams, the 5 Whys, and PDCA have existed for a long time. Error grouping and monitoring are also well-established areas of software engineering.&lt;/p&gt;

&lt;p&gt;The intention here is not to reinvent them or replace existing tools.&lt;/p&gt;

&lt;p&gt;Instead, I wanted to explore how a few of these ideas might fit into an everyday Symfony troubleshooting workflow.&lt;/p&gt;

&lt;p&gt;The result is a deliberately small application-level tool with three main steps.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Identify recurring patterns
&lt;/h3&gt;

&lt;p&gt;Novora KaizenBundle reads existing Monolog log files and groups records using fingerprints derived from their severity, channel, and message.&lt;/p&gt;

&lt;p&gt;It applies limited normalization to certain dynamic values, such as UUIDs and timestamps, before grouping.&lt;/p&gt;

&lt;p&gt;The dashboard uses a Pareto-style chart to show which patterns occur most frequently within the available log sample.&lt;/p&gt;

&lt;p&gt;This can help answer a modest but useful question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which recurring technical symptoms might deserve attention first?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Frequency is not the same as importance. A rarely occurring exception may have a far greater impact than a frequently logged warning.&lt;/p&gt;

&lt;p&gt;The chart is a starting point for investigation, not an automatic priority decision.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Document the investigation
&lt;/h3&gt;

&lt;p&gt;Once a recurring pattern looks worth examining, a developer can open an investigation.&lt;/p&gt;

&lt;p&gt;The bundle provides a small workspace inspired by familiar Lean techniques:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gemba:&lt;/strong&gt; record what was actually observed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ishikawa:&lt;/strong&gt; organize possible contributing causes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5 Whys:&lt;/strong&gt; explore the reasoning behind a problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PDCA:&lt;/strong&gt; document the intended change, its implementation, observations, and follow-up actions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These methods are aids, not mandatory rituals.&lt;/p&gt;

&lt;p&gt;A small issue does not necessarily need a complete Ishikawa analysis or five written explanations.&lt;/p&gt;

&lt;p&gt;One design decision I consider important is that &lt;strong&gt;a hypothesis cannot be marked as confirmed without supporting evidence&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The bundle does not discover root causes automatically. The developer remains responsible for determining whether the evidence is sufficient.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Review what happened after a change
&lt;/h3&gt;

&lt;p&gt;Fixing an exception and observing that it no longer appears are two different things.&lt;/p&gt;

&lt;p&gt;For investigations linked to a log fingerprint, the bundle allows a correction timestamp to be recorded.&lt;/p&gt;

&lt;p&gt;It can then compare matching log-event counts over equal periods before and after that timestamp, using at most a 24-hour comparison window.&lt;/p&gt;

&lt;p&gt;It also checks for later occurrences still present in the available sample.&lt;/p&gt;

&lt;p&gt;This does not prove that a problem has been permanently resolved.&lt;/p&gt;

&lt;p&gt;A quiet log might mean the correction helped. It might also mean the relevant operation was not executed again, or that older logs have already been rotated.&lt;/p&gt;

&lt;p&gt;The bundle therefore reports the observed counts and limitations rather than automatically declaring success.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small example
&lt;/h2&gt;

&lt;p&gt;Consider this simplified, entirely synthetic log entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[2026-10-10T08:00:00+00:00] app.ERROR: Cache directory is not writable [] []
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Suppose the same message appears repeatedly.&lt;/p&gt;

&lt;p&gt;The dashboard can group those matching entries and make their frequency visible.&lt;/p&gt;

&lt;p&gt;A developer may then open an investigation, check the actual filesystem permissions and process identity, and record the findings.&lt;/p&gt;

&lt;p&gt;If the evidence supports a permission problem, the developer can document a corrective action.&lt;/p&gt;

&lt;p&gt;After applying the correction, the developer records when it was made and reviews the matching log entries before and after that point.&lt;/p&gt;

&lt;p&gt;If the same fingerprint appears again, there is a concrete reason to investigate further.&lt;/p&gt;

&lt;p&gt;If it does not appear, the observation is recorded — but the case is not automatically considered resolved.&lt;/p&gt;

&lt;p&gt;This example illustrates the intended workflow. &lt;strong&gt;It is not a production case study or evidence of measured time savings.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the implementation small
&lt;/h2&gt;

&lt;p&gt;The bundle is built for PHP 8.2+ and Symfony 7.4 or 8.x.&lt;/p&gt;

&lt;p&gt;It works with existing supported Monolog file formats, provides a small administrator interface, and stores investigations in local JSON files.&lt;/p&gt;

&lt;p&gt;It does not require its own database schema, external APM server, or hosted telemetry service.&lt;/p&gt;

&lt;p&gt;An optional correlation feature can attach execution identifiers to new HTTP, CLI, and Messenger log records, provided the host application has the appropriate Monolog integration.&lt;/p&gt;

&lt;p&gt;Existing log entries are not retroactively correlated.&lt;/p&gt;

&lt;p&gt;Installation is available through Composer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require novora/kaizen-bundle:^1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bundle must then be registered and configured in the Symfony application. The administrator routes require authentication and &lt;code&gt;ROLE_ADMIN&lt;/code&gt; access, and the configured log and investigation directories need appropriate filesystem permissions.&lt;/p&gt;

&lt;p&gt;The complete setup instructions are available in the &lt;a href="https://github.com/alkinbg/Novora-KaizenBundle#readme" rel="noopener noreferrer"&gt;README&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What version 1.0 does not do
&lt;/h2&gt;

&lt;p&gt;There are important limitations worth making clear.&lt;/p&gt;

&lt;p&gt;The bundle reads a bounded portion of a configured log file — 2 MiB by default — rather than collecting complete application history.&lt;/p&gt;

&lt;p&gt;Its fingerprinting is heuristic. Different errors may occasionally be grouped together, while variations of the same underlying problem may form separate groups.&lt;/p&gt;

&lt;p&gt;Its before-and-after comparison uses observed log counts, not traffic-normalized failure rates.&lt;/p&gt;

&lt;p&gt;It does not provide distributed tracing, automatic root-cause analysis, alerting, or multi-server monitoring.&lt;/p&gt;

&lt;p&gt;Investigation storage is local and intended for a single shared-writer environment. Display redaction is best-effort and should never be treated as permission to log credentials or personal information.&lt;/p&gt;

&lt;p&gt;These are conscious limitations of the first version, not capabilities hidden behind a configuration option.&lt;/p&gt;

&lt;p&gt;For many applications, an established monitoring or error-tracking platform will be the more appropriate choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why release it?
&lt;/h2&gt;

&lt;p&gt;There is no shortage of debugging tools, dashboards, or methodologies.&lt;/p&gt;

&lt;p&gt;I do not expect this bundle to change that landscape.&lt;/p&gt;

&lt;p&gt;The idea is simply to offer another small option for Symfony developers who already have application logs and would like a structured way to move from recurring technical symptoms to documented investigations.&lt;/p&gt;

&lt;p&gt;Version 1.0 has automated tests and has been exercised in separate Symfony 7.4/PHP 8.2 and Symfony 8.1/PHP 8.4 test applications.&lt;/p&gt;

&lt;p&gt;That verifies specific behavior and compatibility scenarios. It does not establish how much time the tool saves in real-world development teams.&lt;/p&gt;

&lt;p&gt;I would rather learn that from actual users than make claims I cannot support.&lt;/p&gt;

&lt;p&gt;If the bundle proves useful, it can improve incrementally. If parts of the workflow feel unnecessary, simplifying them may be more valuable than adding new features.&lt;/p&gt;

&lt;p&gt;That, to me, is very much in the spirit of Kaizen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open source
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Novora KaizenBundle 1.0&lt;/strong&gt; is available under the MIT License.&lt;/p&gt;

&lt;p&gt;The source code, documentation, and tests are public:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/alkinbg/Novora-KaizenBundle" rel="noopener noreferrer"&gt;https://github.com/alkinbg/Novora-KaizenBundle&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Packagist:&lt;/strong&gt; &lt;a href="https://packagist.org/packages/novora/kaizen-bundle" rel="noopener noreferrer"&gt;https://packagist.org/packages/novora/kaizen-bundle&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Feedback, bug reports, and focused contributions are welcome.&lt;/p&gt;

&lt;p&gt;The goal is not to build the biggest tool — just something small enough to understand and useful enough to keep around.&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>php</category>
      <category>kaizen</category>
      <category>lean</category>
    </item>
    <item>
      <title>Lean Software Development in Practice: Finding Muda in Four PHP Projects</title>
      <dc:creator>Alkin Veysal</dc:creator>
      <pubDate>Mon, 28 Sep 2026 11:24:33 +0000</pubDate>
      <link>https://dev.to/alkin/lean-software-development-in-practice-finding-muda-in-four-php-projects-3m7c</link>
      <guid>https://dev.to/alkin/lean-software-development-in-practice-finding-muda-in-four-php-projects-3m7c</guid>
      <description>&lt;p&gt;Lean has one word that I like because it is brutally simple: &lt;strong&gt;Muda&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Waste.&lt;/p&gt;

&lt;p&gt;In a factory, waste is often visible. Extra movement. Waiting. Too much inventory. Producing something before it is needed.&lt;/p&gt;

&lt;p&gt;In software, it is much better at hiding.&lt;/p&gt;

&lt;p&gt;An unused abstraction does not block a corridor. A feature nobody asked for does not sit on a pallet. A public API that became too large does not look like inventory.&lt;/p&gt;

&lt;p&gt;But all of them still cost something.&lt;/p&gt;

&lt;p&gt;They have to be designed, written, tested, documented and maintained. And once other developers start depending on them, removing them becomes much harder than adding them.&lt;/p&gt;

&lt;p&gt;That made me look at four of my own open-source PHP projects from a slightly different angle.&lt;/p&gt;

&lt;p&gt;Three are Symfony bundles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/alkinbg/optimistic-concurrency-bundle" rel="noopener noreferrer"&gt;OptimisticConcurrencyBundle&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/alkinbg/masked-bundle" rel="noopener noreferrer"&gt;MaskedBundle&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/alkinbg/http-idempotency-bundle" rel="noopener noreferrer"&gt;HttpIdempotencyBundle&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fourth is a standalone CLI tool:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/alkinbg/doctrine-migration-guard" rel="noopener noreferrer"&gt;Doctrine Migration Guard&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I have already written separate technical articles about all four projects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/alkin/preventing-lost-updates-in-symfony-apis-with-etags-and-doctrine-1h57"&gt;Preventing Lost Updates in Symfony APIs with ETags and Doctrine&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/alkin/i-made-a-symfony-bundle-for-masking-sensitive-data-3b1f"&gt;I Made a Symfony Bundle for Masking Sensitive Data&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/alkin/catching-risky-doctrine-migrations-before-production-dc"&gt;Catching Risky Doctrine Migrations Before Production&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/alkin/when-http-retries-become-dangerous-idempotency-in-symfony-without-the-fairy-tales-10l5"&gt;When HTTP Retries Become Dangerous: Idempotency in Symfony Without the Fairy Tales&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This time, I do not want to explain again how the packages work.&lt;/p&gt;

&lt;p&gt;The more interesting question is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What did I deliberately choose not to build?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is where the Lean part becomes interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Muda is not the same as "more code"
&lt;/h2&gt;

&lt;p&gt;This was the first thing I had to be careful about.&lt;/p&gt;

&lt;p&gt;Lean software development does &lt;strong&gt;not&lt;/strong&gt; mean deleting code until everything is small.&lt;/p&gt;

&lt;p&gt;Sometimes more code is exactly what prevents a real failure.&lt;/p&gt;

&lt;p&gt;For example, in HttpIdempotencyBundle the idempotency record is read again after acquiring the lock.&lt;/p&gt;

&lt;p&gt;That is extra work.&lt;/p&gt;

&lt;p&gt;It is also necessary.&lt;/p&gt;

&lt;p&gt;Another request may have completed the operation between the first read and the moment the lock was acquired. Skipping the second read would make the code shorter, but also wrong.&lt;/p&gt;

&lt;p&gt;The same happens in OptimisticConcurrencyBundle.&lt;/p&gt;

&lt;p&gt;The HTTP layer checks &lt;code&gt;If-Match&lt;/code&gt;, but Doctrine still performs its own optimistic-lock check during &lt;code&gt;flush()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two checks.&lt;/p&gt;

&lt;p&gt;Two different race windows.&lt;/p&gt;

&lt;p&gt;Both useful.&lt;/p&gt;

&lt;p&gt;So the question is not:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How can this be done with fewer lines?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A better question is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Does this complexity protect something real, or does it exist only because it might be useful one day?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That difference matters.&lt;/p&gt;




&lt;h2&gt;
  
  
  OptimisticConcurrencyBundle: do not build concurrency twice
&lt;/h2&gt;

&lt;p&gt;Repository: &lt;a href="https://github.com/alkinbg/optimistic-concurrency-bundle" rel="noopener noreferrer"&gt;github.com/alkinbg/optimistic-concurrency-bundle&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Earlier article: &lt;a href="https://dev.to/alkin/preventing-lost-updates-in-symfony-apis-with-etags-and-doctrine-1h57"&gt;Preventing Lost Updates in Symfony APIs with ETags and Doctrine&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The bundle solves a specific problem: preventing stale clients from silently overwriting newer data.&lt;/p&gt;

&lt;p&gt;A resource can expose an ETag:&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;#[EntityTag('document', scope: 'document-detail-v1')]&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;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Document&lt;/span&gt; &lt;span class="nv"&gt;$document&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;JsonResponse&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and a write can require the client to send the same representation version back:&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;#[RequireIfMatch('document', scope: 'document-detail-v1')]&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;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Document&lt;/span&gt; &lt;span class="nv"&gt;$document&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;JsonResponse&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is an obvious temptation here.&lt;/p&gt;

&lt;p&gt;Once you start solving concurrency, it is easy to keep going.&lt;/p&gt;

&lt;p&gt;The bundle could maintain its own entity versions.&lt;/p&gt;

&lt;p&gt;It could introduce its own locking mechanism.&lt;/p&gt;

&lt;p&gt;It could control transactions.&lt;/p&gt;

&lt;p&gt;It could call &lt;code&gt;flush()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It could become "the concurrency layer".&lt;/p&gt;

&lt;p&gt;But Doctrine already has optimistic locking through &lt;code&gt;#[ORM\Version]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the bundle does not replace it.&lt;/p&gt;

&lt;p&gt;The HTTP layer answers:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is the representation used by this client stale?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Doctrine answers:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Did the entity change before the database write completed?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That division is useful.&lt;/p&gt;

&lt;p&gt;Building a second persistence-level concurrency system would not remove the need for Doctrine's protection. It would mostly add another implementation to understand and another set of failure cases to maintain.&lt;/p&gt;

&lt;p&gt;That is a form of Muda I see quite often in software:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;rebuilding something that another layer already does well.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There is another small detail in this project that I now appreciate more.&lt;/p&gt;

&lt;p&gt;The supported public API is intentionally small.&lt;/p&gt;

&lt;p&gt;Most implementation classes are internal, and there is an architecture test that protects that boundary.&lt;/p&gt;

&lt;p&gt;Why care?&lt;/p&gt;

&lt;p&gt;Because every public class is a promise.&lt;/p&gt;

&lt;p&gt;If someone starts depending on it, changing it later becomes a backward-compatibility problem.&lt;/p&gt;

&lt;p&gt;A large public API can easily become a kind of inventory: things you now have to keep carrying even when you no longer want them.&lt;/p&gt;

&lt;p&gt;Sometimes the Lean choice is not removing code.&lt;/p&gt;

&lt;p&gt;Sometimes it is simply &lt;strong&gt;not exposing it&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  MaskedBundle: do not try to detect every secret in the universe
&lt;/h2&gt;

&lt;p&gt;Repository: &lt;a href="https://github.com/alkinbg/masked-bundle" rel="noopener noreferrer"&gt;github.com/alkinbg/masked-bundle&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Earlier article: &lt;a href="https://dev.to/alkin/i-made-a-symfony-bundle-for-masking-sensitive-data-3b1f"&gt;I Made a Symfony Bundle for Masking Sensitive Data&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;MaskedBundle started from a practical problem.&lt;/p&gt;

&lt;p&gt;Sensitive data can end up in logs.&lt;/p&gt;

&lt;p&gt;Once you start thinking about automatic detection, the feature list grows very quickly.&lt;/p&gt;

&lt;p&gt;Passwords.&lt;/p&gt;

&lt;p&gt;API tokens.&lt;/p&gt;

&lt;p&gt;JWTs.&lt;/p&gt;

&lt;p&gt;Session IDs.&lt;/p&gt;

&lt;p&gt;Private keys.&lt;/p&gt;

&lt;p&gt;Database credentials.&lt;/p&gt;

&lt;p&gt;Cloud access keys.&lt;/p&gt;

&lt;p&gt;Payment data.&lt;/p&gt;

&lt;p&gt;It is very easy to imagine a "smart" detector for all of them.&lt;/p&gt;

&lt;p&gt;It is also very easy to create a large pile of heuristics.&lt;/p&gt;

&lt;p&gt;The bundle takes a narrower approach.&lt;/p&gt;

&lt;p&gt;Automatic detection is conservative and currently focuses on payment-card candidates where there is enough structure to make a reasonably confident decision.&lt;/p&gt;

&lt;p&gt;When the application already knows a value is sensitive, it can pass it explicitly:&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;$masked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$sensitiveDataMasker&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'Authentication failed for token '&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;sensitiveValues&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$token&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 design looks simple, but the reason behind it matters.&lt;/p&gt;

&lt;p&gt;If the application already knows that &lt;code&gt;$token&lt;/code&gt; is sensitive, the masking library does not need another complicated heuristic to rediscover that fact.&lt;/p&gt;

&lt;p&gt;Trying to detect everything automatically would mean more regexes, more edge cases and, probably, more false confidence.&lt;/p&gt;

&lt;p&gt;There is also another important choice in the implementation: detection work is bounded.&lt;/p&gt;

&lt;p&gt;If a safety budget is exhausted, masking fails closed instead of continuing an expensive scan forever.&lt;/p&gt;

&lt;p&gt;That is not "less code".&lt;/p&gt;

&lt;p&gt;It is extra defensive code with a clear purpose.&lt;/p&gt;

&lt;p&gt;The Muda would be adding twenty more speculative detectors just because the architecture allows it.&lt;/p&gt;

&lt;p&gt;This is one of the traps I have started to notice more often:&lt;/p&gt;

&lt;p&gt;A good abstraction makes new features easy.&lt;/p&gt;

&lt;p&gt;And when new features are easy, it becomes very tempting to add them.&lt;/p&gt;

&lt;p&gt;But "easy to add" and "worth adding" are not the same thing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Doctrine Migration Guard: "I don't know" can be a valid result
&lt;/h2&gt;

&lt;p&gt;Repository: &lt;a href="https://github.com/alkinbg/doctrine-migration-guard" rel="noopener noreferrer"&gt;github.com/alkinbg/doctrine-migration-guard&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Earlier article: &lt;a href="https://dev.to/alkin/catching-risky-doctrine-migrations-before-production-dc"&gt;Catching Risky Doctrine Migrations Before Production&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Doctrine Migration Guard is probably the clearest example of scope control in these four projects.&lt;/p&gt;

&lt;p&gt;Its job is simple to describe:&lt;/p&gt;

&lt;p&gt;Check Doctrine migration files for risky MySQL and MariaDB operations before they reach production.&lt;/p&gt;

&lt;p&gt;The dangerous part is everything that could be added around that idea.&lt;/p&gt;

&lt;p&gt;The tool could connect to the database.&lt;/p&gt;

&lt;p&gt;It could inspect table size.&lt;/p&gt;

&lt;p&gt;It could understand server versions.&lt;/p&gt;

&lt;p&gt;It could discover pending migrations.&lt;/p&gt;

&lt;p&gt;It could support PostgreSQL.&lt;/p&gt;

&lt;p&gt;It could have a plugin system.&lt;/p&gt;

&lt;p&gt;It could read Git history.&lt;/p&gt;

&lt;p&gt;It could rewrite dangerous SQL.&lt;/p&gt;

&lt;p&gt;Every one of those ideas sounds reasonable.&lt;/p&gt;

&lt;p&gt;That does not mean they belong in version &lt;code&gt;0.1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The first release intentionally understands a narrow migration shape.&lt;/p&gt;

&lt;p&gt;This is straightforward:&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;addSql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'ALTER TABLE users ADD nickname VARCHAR(64) DEFAULT NULL'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not:&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;$sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ALTER TABLE users ADD nickname VARCHAR(64)'&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;addSql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sql&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tool does not try to guess what dynamic PHP code will do.&lt;/p&gt;

&lt;p&gt;It reports the result as incomplete.&lt;/p&gt;

&lt;p&gt;The same happens with SQL constructs that the current analyzer cannot classify safely.&lt;/p&gt;

&lt;p&gt;They become &lt;code&gt;UNANALYZED&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;At first glance, this looks like weakness.&lt;/p&gt;

&lt;p&gt;I think it is the opposite.&lt;/p&gt;

&lt;p&gt;A static analyzer that understands only part of a construct and still returns green is more dangerous than one that says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I don't know.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is where Lean and safety meet quite nicely.&lt;/p&gt;

&lt;p&gt;Supporting every possible migration shape would require much more parsing, much more testing and many more edge cases.&lt;/p&gt;

&lt;p&gt;If the actual value of the first release is catching common risky migrations in CI, then building everything else before that idea proves useful is simply overproduction.&lt;/p&gt;

&lt;p&gt;Software people often call that future-proofing.&lt;/p&gt;

&lt;p&gt;Sometimes it really is.&lt;/p&gt;

&lt;p&gt;Sometimes it is just work arriving too early.&lt;/p&gt;




&lt;h2&gt;
  
  
  HttpIdempotencyBundle: do not promise exactly-once when you cannot deliver it
&lt;/h2&gt;

&lt;p&gt;Repository: &lt;a href="https://github.com/alkinbg/http-idempotency-bundle" rel="noopener noreferrer"&gt;github.com/alkinbg/http-idempotency-bundle&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Earlier article: &lt;a href="https://dev.to/alkin/when-http-retries-become-dangerous-idempotency-in-symfony-without-the-fairy-tales-10l5"&gt;When HTTP Retries Become Dangerous: Idempotency in Symfony Without the Fairy Tales&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Idempotency is another area where it is very easy to over-promise.&lt;/p&gt;

&lt;p&gt;The bundle protects selected controller actions from accidental duplicate execution when clients retry HTTP requests.&lt;/p&gt;

&lt;p&gt;Protection is explicit:&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;#[Idempotent]&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;createOrder&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;JsonResponse&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It could have been automatic.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;p&gt;"Every POST, PUT and PATCH should be idempotent."&lt;/p&gt;

&lt;p&gt;That sounds convenient.&lt;/p&gt;

&lt;p&gt;It is also a huge assumption.&lt;/p&gt;

&lt;p&gt;Not every endpoint has the same semantics. Automatically changing every write request would make the bundle responsible for application behavior it cannot fully understand.&lt;/p&gt;

&lt;p&gt;So the controller has to opt in.&lt;/p&gt;

&lt;p&gt;More importantly, the bundle does &lt;strong&gt;not&lt;/strong&gt; claim exactly-once execution.&lt;/p&gt;

&lt;p&gt;Because it cannot guarantee it.&lt;/p&gt;

&lt;p&gt;Imagine this:&lt;/p&gt;

&lt;p&gt;A controller calls an external payment provider.&lt;/p&gt;

&lt;p&gt;The payment succeeds.&lt;/p&gt;

&lt;p&gt;Before the completed idempotency record is saved, the PHP process crashes.&lt;/p&gt;

&lt;p&gt;The side effect has already happened.&lt;/p&gt;

&lt;p&gt;No Symfony event subscriber can travel back in time and undo that payment.&lt;/p&gt;

&lt;p&gt;Adding more internal machinery does not change this fundamental failure window.&lt;/p&gt;

&lt;p&gt;So the bundle handles the part it can actually control:&lt;/p&gt;

&lt;p&gt;request identity, fingerprints, shared state, locking and response replay.&lt;/p&gt;

&lt;p&gt;Other guarantees remain where they belong:&lt;/p&gt;

&lt;p&gt;database constraints, transactions, provider-side idempotency, outbox patterns and domain-specific protection.&lt;/p&gt;

&lt;p&gt;For me, this is another form of Lean thinking:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;do not spend complexity trying to create a guarantee your layer cannot honestly provide.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;More code would not make the promise true.&lt;/p&gt;

&lt;p&gt;It would only make the implementation bigger.&lt;/p&gt;




&lt;h2&gt;
  
  
  The interesting part was not what the projects contain
&lt;/h2&gt;

&lt;p&gt;After looking at the four repositories this way, something surprised me.&lt;/p&gt;

&lt;p&gt;I expected the best examples of Muda to be dead code, duplicated methods or unnecessary classes.&lt;/p&gt;

&lt;p&gt;Those things matter.&lt;/p&gt;

&lt;p&gt;But the more interesting waste appears earlier.&lt;/p&gt;

&lt;p&gt;It appears when deciding what the software should become.&lt;/p&gt;

&lt;p&gt;In OptimisticConcurrencyBundle, the important decision was not to replace Doctrine.&lt;/p&gt;

&lt;p&gt;In MaskedBundle, it was not to pretend every type of secret can be detected automatically.&lt;/p&gt;

&lt;p&gt;In Doctrine Migration Guard, it was not to support every migration shape in the first release.&lt;/p&gt;

&lt;p&gt;In HttpIdempotencyBundle, it was not to enable idempotency everywhere and not to claim exactly-once execution.&lt;/p&gt;

&lt;p&gt;None of those decisions look impressive in an architecture diagram.&lt;/p&gt;

&lt;p&gt;That may be exactly why they are easy to miss.&lt;/p&gt;

&lt;p&gt;Sometimes avoiding waste does not result in a clever class.&lt;/p&gt;

&lt;p&gt;Sometimes the result is that &lt;strong&gt;the class never gets written&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  A small checklist before adding "just one more thing"
&lt;/h2&gt;

&lt;p&gt;These are the questions I now try to ask before adding another feature, abstraction or extension point:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is there a real use case now, or only a possible future one?&lt;/li&gt;
&lt;li&gt;Does another layer already solve this problem?&lt;/li&gt;
&lt;li&gt;Am I adding an abstraction before I have a second real case for it?&lt;/li&gt;
&lt;li&gt;Am I making the public API larger than necessary?&lt;/li&gt;
&lt;li&gt;If I cannot know something safely, is "unknown" better than a guess?&lt;/li&gt;
&lt;li&gt;Will this feature create enough value to justify its tests, documentation and future compatibility cost?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I do not always get this right.&lt;/p&gt;

&lt;p&gt;Building things is enjoyable. That is part of the problem.&lt;/p&gt;

&lt;p&gt;A generic solution feels elegant.&lt;/p&gt;

&lt;p&gt;A new extension point feels flexible.&lt;/p&gt;

&lt;p&gt;Another supported scenario makes the README look stronger.&lt;/p&gt;

&lt;p&gt;But every feature creates work after it is merged.&lt;/p&gt;

&lt;p&gt;That part is much less exciting, and much easier to forget.&lt;/p&gt;




&lt;h2&gt;
  
  
  One final thought
&lt;/h2&gt;

&lt;p&gt;Lean and software development are not a perfect translation of each other.&lt;/p&gt;

&lt;p&gt;Code is not a production line, and forcing every programming problem into one of the classic Lean wastes would not be very useful.&lt;/p&gt;

&lt;p&gt;But the basic idea behind Muda translates extremely well:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Effort is not the same as value.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A complicated solution can be necessary.&lt;/p&gt;

&lt;p&gt;A simple solution can be wrong.&lt;/p&gt;

&lt;p&gt;The goal is not minimal code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The goal is to spend complexity where it protects something real.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That changed one question for me.&lt;/p&gt;

&lt;p&gt;Instead of always asking:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What else should this support?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;there is another question that is often more useful:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What happens if this is not built?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Sometimes the answer is:&lt;/p&gt;

&lt;p&gt;Nothing.&lt;/p&gt;

&lt;p&gt;And sometimes that is the best possible result.&lt;/p&gt;

</description>
      <category>lean</category>
      <category>muda</category>
      <category>php</category>
      <category>symfony</category>
    </item>
    <item>
      <title>When HTTP Retries Become Dangerous: Idempotency in Symfony Without the Fairy Tales</title>
      <dc:creator>Alkin Veysal</dc:creator>
      <pubDate>Sun, 30 Aug 2026 21:12:29 +0000</pubDate>
      <link>https://dev.to/alkin/when-http-retries-become-dangerous-idempotency-in-symfony-without-the-fairy-tales-10l5</link>
      <guid>https://dev.to/alkin/when-http-retries-become-dangerous-idempotency-in-symfony-without-the-fairy-tales-10l5</guid>
      <description>&lt;p&gt;Retries are one of those things that look harmless until the first time they duplicate a real business operation.&lt;/p&gt;

&lt;p&gt;A request times out, so the client retries it.&lt;/p&gt;

&lt;p&gt;Reasonable.&lt;/p&gt;

&lt;p&gt;But what if the first request actually reached the server?&lt;/p&gt;

&lt;p&gt;What if the application already created the order, reserved the stock, sent the message, or called a payment provider — and only the response was lost?&lt;/p&gt;

&lt;p&gt;From the client's point of view, the request failed.&lt;/p&gt;

&lt;p&gt;From the application's point of view, it may already be finished.&lt;/p&gt;

&lt;p&gt;Send the same request again and you can get the worst kind of bug: one that is technically understandable, difficult to reproduce, and very expensive in production.&lt;/p&gt;

&lt;p&gt;This is the problem that pushed me to build &lt;a href="https://github.com/alkinbg/http-idempotency-bundle" rel="noopener noreferrer"&gt;HttpIdempotencyBundle&lt;/a&gt;, a small Symfony bundle for explicit HTTP request idempotency.&lt;/p&gt;

&lt;p&gt;But the interesting part is not the bundle itself.&lt;/p&gt;

&lt;p&gt;The interesting part is everything that has to be true before we can safely say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"This request is a retry of the same operation, so we should not execute it again."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And just as importantly, what we &lt;strong&gt;cannot&lt;/strong&gt; guarantee.&lt;/p&gt;




&lt;h2&gt;
  
  
  A timeout does not mean the operation failed
&lt;/h2&gt;

&lt;p&gt;Consider a simple endpoint:&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('/orders', methods: ['POST'])]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;createOrder&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;JsonResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$order&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;orderService&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&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;$order&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="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now imagine this sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client -&amp;gt; POST /orders
Server -&amp;gt; creates order #742
Server -&amp;gt; sends 201 response
Network -&amp;gt; connection dies
Client -&amp;gt; sees timeout
Client -&amp;gt; retries POST /orders
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing unusual happened.&lt;/p&gt;

&lt;p&gt;The client did exactly what clients often do after a timeout.&lt;/p&gt;

&lt;p&gt;The server did exactly what it was asked to do.&lt;/p&gt;

&lt;p&gt;And yet, unless we have another mechanism in place, we may now create order #743 as well.&lt;/p&gt;

&lt;p&gt;The key idea is simple:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;transport failure and business-operation failure are not the same thing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;HTTP cannot always tell the client whether the operation happened.&lt;/p&gt;




&lt;h2&gt;
  
  
  Give the operation an identity
&lt;/h2&gt;

&lt;p&gt;A common solution is an &lt;code&gt;Idempotency-Key&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The client generates a unique value for one logical operation and sends it with every retry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nf"&gt;POST&lt;/span&gt; &lt;span class="nn"&gt;/orders&lt;/span&gt; &lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&lt;/span&gt;
&lt;span class="na"&gt;Idempotency-Key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;order-7f98b773&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"sku"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ABC-42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"quantity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the request must be retried, the same key is reused.&lt;/p&gt;

&lt;p&gt;That gives the server a stable identity to work with.&lt;/p&gt;

&lt;p&gt;At first, the implementation seems 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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$store&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;))&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;$store&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;);&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;$store&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$response&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;$response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unfortunately, this is not enough.&lt;/p&gt;

&lt;p&gt;There are several traps hiding inside those few lines.&lt;/p&gt;




&lt;h2&gt;
  
  
  A key alone is not enough
&lt;/h2&gt;

&lt;p&gt;What should happen if a client accidentally reuses the same key for a different request?&lt;/p&gt;

&lt;p&gt;First request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Idempotency-Key: order-7f98b773

{"sku":"ABC-42","quantity":1}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Idempotency-Key: order-7f98b773

{"sku":"XYZ-99","quantity":10}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If we only store the key, the second request might receive the response from the first one.&lt;/p&gt;

&lt;p&gt;That would be worse than simply failing.&lt;/p&gt;

&lt;p&gt;The server must therefore know not only &lt;strong&gt;which key&lt;/strong&gt; was used, but also &lt;strong&gt;which request&lt;/strong&gt; that key belongs to.&lt;/p&gt;

&lt;p&gt;This is where request fingerprinting becomes important.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fingerprint the request, not just the key
&lt;/h2&gt;

&lt;p&gt;A deterministic fingerprint can describe the concrete request.&lt;/p&gt;

&lt;p&gt;For HttpIdempotencyBundle, the default fingerprint includes the important parts of the request, such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HTTP method&lt;/li&gt;
&lt;li&gt;logical operation&lt;/li&gt;
&lt;li&gt;path&lt;/li&gt;
&lt;li&gt;normalized query parameters&lt;/li&gt;
&lt;li&gt;normalized &lt;code&gt;Content-Type&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;raw request body&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is represented as a SHA-256 digest.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST
/orders
application/json
{"sku":"ABC-42","quantity":1}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sha256(...)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now there are two very different cases.&lt;/p&gt;

&lt;h3&gt;
  
  
  Same key, same fingerprint
&lt;/h3&gt;

&lt;p&gt;This is the same logical request again.&lt;/p&gt;

&lt;p&gt;If the first request already completed, we can replay its stored response.&lt;/p&gt;

&lt;h3&gt;
  
  
  Same key, different fingerprint
&lt;/h3&gt;

&lt;p&gt;The key is being reused for another request.&lt;/p&gt;

&lt;p&gt;That should not silently replay anything.&lt;/p&gt;

&lt;p&gt;In the bundle, this becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;422 Unprocessable Content
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is an important detail.&lt;/p&gt;

&lt;p&gt;Idempotency should protect us from duplicate execution, not hide application bugs.&lt;/p&gt;




&lt;h2&gt;
  
  
  The race condition in the obvious solution
&lt;/h2&gt;

&lt;p&gt;Now we get to the part that makes idempotency a concurrency problem.&lt;/p&gt;

&lt;p&gt;Imagine two identical requests arrive at almost the same time.&lt;/p&gt;

&lt;p&gt;Worker A:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;read -&amp;gt; no record
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worker B:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;read -&amp;gt; no record
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worker A executes the controller.&lt;/p&gt;

&lt;p&gt;Worker B executes the controller too.&lt;/p&gt;

&lt;p&gt;Both workers followed the logic correctly.&lt;/p&gt;

&lt;p&gt;The result is still wrong.&lt;/p&gt;

&lt;p&gt;A cache lookup alone cannot serialize concurrent execution.&lt;/p&gt;

&lt;p&gt;We need coordination.&lt;/p&gt;




&lt;h2&gt;
  
  
  Shared state and a shared lock
&lt;/h2&gt;

&lt;p&gt;The bundle uses two separate pieces:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;shared idempotency state&lt;/li&gt;
&lt;li&gt;Symfony Lock&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both must really be shared across the application.&lt;/p&gt;

&lt;p&gt;That distinction matters in production.&lt;/p&gt;

&lt;p&gt;If you run multiple PHP workers, containers, or application nodes, a local in-memory cache or a local filesystem lock cannot coordinate all of them.&lt;/p&gt;

&lt;p&gt;Redis is one practical option:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;framework&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;pools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;cache.http_idempotency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;adapter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cache.adapter.redis&lt;/span&gt;
                &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%env(REDIS_URL)%'&lt;/span&gt;

    &lt;span class="na"&gt;lock&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;http_idempotency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%env(REDIS_URL)%'&lt;/span&gt;

&lt;span class="na"&gt;http_idempotency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cache_pool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cache.http_idempotency&lt;/span&gt;
    &lt;span class="na"&gt;lock_factory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lock.http_idempotency.factory&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Redis itself is not the requirement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shared visibility is the requirement.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every application instance participating in the same operation must see the same idempotency state and the same locking mechanism.&lt;/p&gt;




&lt;h2&gt;
  
  
  The second read is easy to miss
&lt;/h2&gt;

&lt;p&gt;There is a subtler race condition.&lt;/p&gt;

&lt;p&gt;Suppose request A and request B both perform their first read before either one obtains the lock.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A: read -&amp;gt; no record
B: read -&amp;gt; no record
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A gets the lock first.&lt;/p&gt;

&lt;p&gt;It executes the controller, stores the completed response, and releases the lock.&lt;/p&gt;

&lt;p&gt;B then acquires the lock.&lt;/p&gt;

&lt;p&gt;If B trusts its old read, it still believes there is no record.&lt;/p&gt;

&lt;p&gt;So it could execute the controller again.&lt;/p&gt;

&lt;p&gt;The fix is small but essential:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Read the idempotency record again after acquiring the lock.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The simplified flow becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;read record

completed + same fingerprint
    -&amp;gt; replay

completed + different fingerprint
    -&amp;gt; 422

try to acquire lock

lock unavailable
    -&amp;gt; 409

lock acquired

read record again

completed + same fingerprint
    -&amp;gt; replay

completed + different fingerprint
    -&amp;gt; 422

still no record
    -&amp;gt; save processing marker
    -&amp;gt; execute controller
    -&amp;gt; store completed response
    -&amp;gt; release lock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second read closes the race between the initial lookup and lock acquisition.&lt;/p&gt;

&lt;p&gt;It is only one extra read.&lt;/p&gt;

&lt;p&gt;It is also one of the most important reads in the whole implementation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the lock is non-blocking
&lt;/h2&gt;

&lt;p&gt;There is another design choice: should a duplicate request wait until the first request finishes?&lt;/p&gt;

&lt;p&gt;It can.&lt;/p&gt;

&lt;p&gt;But that also means tying up a worker while another request is running, with latency controlled by somebody else's operation.&lt;/p&gt;

&lt;p&gt;HttpIdempotencyBundle uses non-blocking lock acquisition.&lt;/p&gt;

&lt;p&gt;If another request is already processing the same idempotency identity, the duplicate request gets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;409 Conflict
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client can then decide whether and when to retry.&lt;/p&gt;

&lt;p&gt;I prefer this because the behavior is explicit and request latency remains predictable.&lt;/p&gt;




&lt;h2&gt;
  
  
  "Same key" should not mean "same key globally"
&lt;/h2&gt;

&lt;p&gt;Suppose two authenticated users both happen to send:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Idempotency-Key: request-123
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They should not collide with each other.&lt;/p&gt;

&lt;p&gt;So the real identity cannot be just the raw idempotency key.&lt;/p&gt;

&lt;p&gt;Conceptually, the bundle derives an identity from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;scope + operation + key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;operation&lt;/strong&gt; tells us which protected application action this belongs to.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;scope&lt;/strong&gt; separates independent principals.&lt;/p&gt;

&lt;p&gt;For authenticated Symfony users, the default scope includes the user identity.&lt;/p&gt;

&lt;p&gt;Anonymous requests use a shared anonymous scope by default.&lt;/p&gt;

&lt;p&gt;Applications that need a different definition can provide their own scope resolver.&lt;/p&gt;

&lt;p&gt;This matters especially for endpoints where authentication happens somewhere else in the stack or where multiple logical principals share the same anonymous HTTP context.&lt;/p&gt;




&lt;h2&gt;
  
  
  Response replay is more complicated than serialization
&lt;/h2&gt;

&lt;p&gt;Suppose the first execution returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;201&lt;/span&gt; &lt;span class="ne"&gt;Created&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;742&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An identical retry should be able to receive that application result without running the controller again.&lt;/p&gt;

&lt;p&gt;But an HTTP response contains more than application data.&lt;/p&gt;

&lt;p&gt;Some headers belong to the original connection or original request environment.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Connection&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Content-Length&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Date&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Server&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Transfer-Encoding&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Set-Cookie&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Blindly storing and replaying the whole response would copy things that should be generated again.&lt;/p&gt;

&lt;p&gt;Cookies are particularly sensitive here.&lt;/p&gt;

&lt;p&gt;A response cookie from the original request should not accidentally become part of a cached idempotency replay.&lt;/p&gt;

&lt;p&gt;The bundle therefore stores a controlled response snapshot rather than serializing the entire Symfony &lt;code&gt;Response&lt;/code&gt; object.&lt;/p&gt;

&lt;p&gt;On replay, normal Symfony response listeners still get a chance to generate fresh request-specific behavior.&lt;/p&gt;

&lt;p&gt;This keeps the idempotency layer focused on the reusable application result.&lt;/p&gt;




&lt;h2&gt;
  
  
  What about application errors?
&lt;/h2&gt;

&lt;p&gt;Here is another question that does not have an obvious answer at first:&lt;/p&gt;

&lt;p&gt;If the controller runs and returns a &lt;code&gt;4xx&lt;/code&gt; or &lt;code&gt;5xx&lt;/code&gt; response, should a retry execute the controller again?&lt;/p&gt;

&lt;p&gt;Not necessarily.&lt;/p&gt;

&lt;p&gt;The important distinction is whether the &lt;strong&gt;application operation completed&lt;/strong&gt; versus whether the &lt;strong&gt;idempotency infrastructure failed&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If the controller genuinely executed and returned an application response, the bundle treats that as a completed execution that can be replayed.&lt;/p&gt;

&lt;p&gt;That avoids turning every application error into another execution attempt.&lt;/p&gt;

&lt;p&gt;Infrastructure failures are different.&lt;/p&gt;

&lt;p&gt;If the idempotency store or lock backend fails before the controller can safely run, the bundle fails closed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;503 Service Unavailable
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If an endpoint explicitly requires idempotency protection, silently continuing without that protection would be a dangerous fallback.&lt;/p&gt;




&lt;h2&gt;
  
  
  A processing marker is useful after crashes
&lt;/h2&gt;

&lt;p&gt;A lock protects concurrent execution while the process is alive.&lt;/p&gt;

&lt;p&gt;But processes crash.&lt;/p&gt;

&lt;p&gt;The application can die after marking the operation as in progress and before storing the completed response.&lt;/p&gt;

&lt;p&gt;For that reason, the bundle also uses a processing marker with a TTL.&lt;/p&gt;

&lt;p&gt;The marker is intentionally not eagerly removed during abnormal cleanup.&lt;/p&gt;

&lt;p&gt;Its expiration creates a bounded recovery window.&lt;/p&gt;

&lt;p&gt;During that period, retries do not immediately re-execute an operation whose previous execution may still have produced an external side effect.&lt;/p&gt;

&lt;p&gt;This does not eliminate every failure mode.&lt;/p&gt;

&lt;p&gt;It makes the failure behavior more controlled.&lt;/p&gt;




&lt;h2&gt;
  
  
  The part that should never be hidden: this is not exactly-once
&lt;/h2&gt;

&lt;p&gt;This is the most important limitation in the whole design.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. controller calls an external payment provider
2. payment succeeds
3. PHP process crashes
4. completed idempotency response is never stored
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The business side effect happened.&lt;/p&gt;

&lt;p&gt;The HTTP idempotency layer did not get the chance to record completion.&lt;/p&gt;

&lt;p&gt;After the processing marker eventually expires, a retry may execute the operation again.&lt;/p&gt;

&lt;p&gt;That failure window exists.&lt;/p&gt;

&lt;p&gt;A middleware, bundle, cache, or distributed lock cannot simply wish it away.&lt;/p&gt;

&lt;p&gt;So HTTP idempotency should not be sold as "exactly-once execution".&lt;/p&gt;

&lt;p&gt;For important writes, it should be combined with domain-level guarantees such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;database unique constraints&lt;/li&gt;
&lt;li&gt;transactions&lt;/li&gt;
&lt;li&gt;provider-level idempotency&lt;/li&gt;
&lt;li&gt;transactional outbox patterns&lt;/li&gt;
&lt;li&gt;durable jobs&lt;/li&gt;
&lt;li&gt;domain-specific deduplication&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example, if a payment provider supports its own idempotency key, use it.&lt;/p&gt;

&lt;p&gt;The HTTP idempotency layer and the provider-level guarantee solve related but different parts of the problem.&lt;/p&gt;

&lt;p&gt;This is one reason I wanted the limitation to be prominent in the documentation rather than hidden in a footnote.&lt;/p&gt;




&lt;h2&gt;
  
  
  Making the choice explicit in Symfony
&lt;/h2&gt;

&lt;p&gt;I also did not want the bundle to automatically change every &lt;code&gt;POST&lt;/code&gt;, &lt;code&gt;PUT&lt;/code&gt;, or &lt;code&gt;PATCH&lt;/code&gt; endpoint.&lt;/p&gt;

&lt;p&gt;Idempotency is an application decision.&lt;/p&gt;

&lt;p&gt;It should be visible in the controller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Alkin&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nc"&gt;\HttpIdempotencyBundle&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nc"&gt;\Attribute&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nc"&gt;\Idempotent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[Route('/orders', methods: ['POST'])]&lt;/span&gt;
&lt;span class="na"&gt;#[Idempotent]&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;createOrder&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;JsonResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Perform the application operation.&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'created'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unmarked controllers are not touched.&lt;/p&gt;

&lt;p&gt;That makes the behavior easy to discover during code review.&lt;/p&gt;

&lt;p&gt;You can look at the endpoint and immediately see that retries are part of its contract.&lt;/p&gt;




&lt;h2&gt;
  
  
  Failure behavior should be boring
&lt;/h2&gt;

&lt;p&gt;One of my goals was to make the failure model small enough to reason about.&lt;/p&gt;

&lt;p&gt;For protected controllers, the bundle uses a few clear outcomes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;400 Bad Request&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Missing or invalid idempotency key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;409 Conflict&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The same operation is already processing, or its lock cannot be acquired&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;422 Unprocessable Content&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The key was reused for a different request fingerprint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;503 Service Unavailable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The idempotency storage or locking infrastructure cannot safely protect the operation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The interesting thing is not the exact status code list.&lt;/p&gt;

&lt;p&gt;It is that the implementation tries to fail &lt;strong&gt;explicitly&lt;/strong&gt; rather than silently weakening the guarantee.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I turned this into a bundle
&lt;/h2&gt;

&lt;p&gt;The original problem sounds small:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Do not execute the same retried HTTP request twice."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But following it far enough touches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;request identity&lt;/li&gt;
&lt;li&gt;request fingerprinting&lt;/li&gt;
&lt;li&gt;concurrency&lt;/li&gt;
&lt;li&gt;distributed locking&lt;/li&gt;
&lt;li&gt;shared state&lt;/li&gt;
&lt;li&gt;response replay&lt;/li&gt;
&lt;li&gt;HTTP headers&lt;/li&gt;
&lt;li&gt;authenticated scope&lt;/li&gt;
&lt;li&gt;process crashes&lt;/li&gt;
&lt;li&gt;failure recovery&lt;/li&gt;
&lt;li&gt;the boundary between transport guarantees and business guarantees&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That made it a useful problem to isolate into a small Symfony component.&lt;/p&gt;

&lt;p&gt;The first stable release is now available as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require alkinbg/http-idempotency-bundle
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It supports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PHP &amp;gt;= 8.2
Symfony 7.4 or 8.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The public repository is here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/alkinbg/http-idempotency-bundle" rel="noopener noreferrer"&gt;github.com/alkinbg/http-idempotency-bundle&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And the package is on Packagist:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://packagist.org/packages/alkinbg/http-idempotency-bundle" rel="noopener noreferrer"&gt;packagist.org/packages/alkinbg/http-idempotency-bundle&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Final thought
&lt;/h2&gt;

&lt;p&gt;Retries are not the enemy.&lt;/p&gt;

&lt;p&gt;They are a normal part of distributed systems.&lt;/p&gt;

&lt;p&gt;The dangerous part is pretending that a timeout tells us what happened on the other side.&lt;/p&gt;

&lt;p&gt;A good idempotency mechanism gives the server enough information to recognize the same logical request, enough coordination to stop concurrent duplicate execution, and a clear enough failure model that we still understand what can go wrong.&lt;/p&gt;

&lt;p&gt;That last part matters.&lt;/p&gt;

&lt;p&gt;Because in distributed systems, the most useful guarantee is often not the one that sounds strongest.&lt;/p&gt;

&lt;p&gt;It is the one whose limits you can explain.&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>php</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Catching Risky Doctrine Migrations Before Production</title>
      <dc:creator>Alkin Veysal</dc:creator>
      <pubDate>Fri, 28 Aug 2026 07:28:55 +0000</pubDate>
      <link>https://dev.to/alkin/catching-risky-doctrine-migrations-before-production-dc</link>
      <guid>https://dev.to/alkin/catching-risky-doctrine-migrations-before-production-dc</guid>
      <description>&lt;p&gt;Database migrations are one of those things that often look harmless during code review.&lt;/p&gt;

&lt;p&gt;A migration may contain only a few lines of SQL, the application tests are green, and everything seems ready to deploy.&lt;/p&gt;

&lt;p&gt;But a small migration can still contain something like 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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;up&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Schema&lt;/span&gt; &lt;span class="nv"&gt;$schema&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="nf"&gt;addSql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ALTER TABLE users DROP COLUMN legacy_code'&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;Or 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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;up&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Schema&lt;/span&gt; &lt;span class="nv"&gt;$schema&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="nf"&gt;addSql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DELETE FROM audit_log'&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;Both are valid migrations.&lt;/p&gt;

&lt;p&gt;They may even be intentional.&lt;/p&gt;

&lt;p&gt;But they are also operations that deserve more attention before they reach production.&lt;/p&gt;

&lt;p&gt;That is the problem I wanted to make easier to catch in CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap between valid and safe-looking
&lt;/h2&gt;

&lt;p&gt;Doctrine Migrations does its job well: it gives us a structured way to manage database changes.&lt;/p&gt;

&lt;p&gt;But Doctrine does not try to decide whether our SQL is risky.&lt;/p&gt;

&lt;p&gt;And normal PHP static analysis is usually not interested in the meaning of SQL inside:&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;addSql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'...'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a migration can be perfectly valid PHP and still contain a database change that should stop a deployment for review.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight 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;addSql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'ALTER TABLE customers ADD external_id VARCHAR(255) NOT NULL'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding a &lt;code&gt;NOT NULL&lt;/code&gt; column without a default may be completely reasonable in one situation and problematic in another.&lt;/p&gt;

&lt;p&gt;The important point is not to automatically declare it "unsafe".&lt;/p&gt;

&lt;p&gt;The important point is to &lt;strong&gt;notice it before deployment&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That idea became &lt;strong&gt;Doctrine Migration Guard&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Doctrine Migration Guard does
&lt;/h2&gt;

&lt;p&gt;Doctrine Migration Guard is a small standalone CLI tool for static analysis of Doctrine migrations targeting MySQL and MariaDB.&lt;/p&gt;

&lt;p&gt;It does not boot Symfony.&lt;/p&gt;

&lt;p&gt;It does not connect to your database.&lt;/p&gt;

&lt;p&gt;It does not execute your migrations.&lt;/p&gt;

&lt;p&gt;It parses the migration source code, looks at supported &lt;code&gt;addSql()&lt;/code&gt; calls inside &lt;code&gt;up()&lt;/code&gt;, and classifies recognized SQL operations by risk.&lt;/p&gt;

&lt;p&gt;Install it as a development dependency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require &lt;span class="nt"&gt;--dev&lt;/span&gt; alkinbg/doctrine-migration-guard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then analyze your migrations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vendor/bin/doctrine-migration-guard migrations/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also analyze one migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vendor/bin/doctrine-migration-guard &lt;span class="se"&gt;\&lt;/span&gt;
    migrations/Version20260828090000.php
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or several files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vendor/bin/doctrine-migration-guard &lt;span class="se"&gt;\&lt;/span&gt;
    migrations/A.php &lt;span class="se"&gt;\&lt;/span&gt;
    migrations/B.php
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Risk levels instead of a simple yes/no
&lt;/h2&gt;

&lt;p&gt;I did not want the tool to pretend that database migration safety can be reduced to a boolean.&lt;/p&gt;

&lt;p&gt;Instead, findings have different levels:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;INFO&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Creating a table or adding a nullable column&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WARNING&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Normal index, foreign key operation, or data change with a top-level &lt;code&gt;WHERE&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HIGH&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;UPDATE&lt;/code&gt; without a top-level &lt;code&gt;WHERE&lt;/code&gt;, rename, unique index, or some &lt;code&gt;NOT NULL&lt;/code&gt; additions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CRITICAL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;DROP TABLE&lt;/code&gt;, &lt;code&gt;DROP COLUMN&lt;/code&gt;, &lt;code&gt;TRUNCATE&lt;/code&gt;, or &lt;code&gt;DELETE&lt;/code&gt; without a top-level &lt;code&gt;WHERE&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UNANALYZED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The tool cannot classify the construct reliably&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last one is especially important.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fail closed instead of guessing
&lt;/h2&gt;

&lt;p&gt;Static analysis becomes dangerous when the tool starts pretending it understands code that it actually does not understand.&lt;/p&gt;

&lt;p&gt;Consider:&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;$sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getMigrationSql&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;addSql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sql&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Doctrine Migration Guard cannot know what &lt;code&gt;getMigrationSql()&lt;/code&gt; returns without executing application code.&lt;/p&gt;

&lt;p&gt;So it does not guess.&lt;/p&gt;

&lt;p&gt;The migration becomes &lt;code&gt;INCOMPLETE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The same principle applies to unsupported control flow and ambiguous SQL.&lt;/p&gt;

&lt;p&gt;For example, a multi-action statement like this is intentionally not partially classified:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;
    &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="n"&gt;last_login_at&lt;/span&gt; &lt;span class="nb"&gt;DATETIME&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="n"&gt;last_seen_at&lt;/span&gt; &lt;span class="nb"&gt;DATETIME&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It would be easy to inspect the first action and produce a result.&lt;/p&gt;

&lt;p&gt;It would also be misleading.&lt;/p&gt;

&lt;p&gt;For the first release, I prefer a conservative answer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I cannot analyze this reliably.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is much more useful in CI than a false green result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doctrine lifecycle hooks need the same treatment
&lt;/h2&gt;

&lt;p&gt;Doctrine migrations can also contain lifecycle methods such as:&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;preUp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Schema&lt;/span&gt; &lt;span class="nv"&gt;$schema&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="nf"&gt;addSql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DROP TABLE old_users'&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;Version &lt;code&gt;0.1&lt;/code&gt; does not analyze &lt;code&gt;preUp()&lt;/code&gt; or &lt;code&gt;postUp()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If one of these hooks is overridden, the migration becomes &lt;code&gt;INCOMPLETE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Supported SQL inside &lt;code&gt;up()&lt;/code&gt; is still analyzed and reported, but the complete migration cannot be considered fully analyzed.&lt;/p&gt;

&lt;p&gt;Again, the goal is not to pretend that unsupported code does not exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Useful exit codes for CI
&lt;/h2&gt;

&lt;p&gt;The CLI uses deterministic exit codes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 = PASSED
1 = FAILED
2 = INCOMPLETE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;FAILED&lt;/code&gt; means at least one &lt;code&gt;HIGH&lt;/code&gt; or &lt;code&gt;CRITICAL&lt;/code&gt; finding was detected.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;INCOMPLETE&lt;/code&gt; has higher priority because some part of the input could not be analyzed reliably.&lt;/p&gt;

&lt;p&gt;That makes it easy to add the tool to CI.&lt;/p&gt;

&lt;p&gt;For example, to analyze migrations changed on a branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git diff &lt;span class="nt"&gt;--name-only&lt;/span&gt; origin/main...HEAD &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'migrations/*.php'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | xargs &lt;span class="nt"&gt;-r&lt;/span&gt; vendor/bin/doctrine-migration-guard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or, if the number of migrations is reasonable, simply scan the entire directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vendor/bin/doctrine-migration-guard migrations/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is also JSON output for automation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vendor/bin/doctrine-migration-guard &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json &lt;span class="se"&gt;\&lt;/span&gt;
    migrations/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JSON format currently uses schema version &lt;code&gt;1&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it deliberately does not do
&lt;/h2&gt;

&lt;p&gt;This part matters.&lt;/p&gt;

&lt;p&gt;Doctrine Migration Guard does &lt;strong&gt;not&lt;/strong&gt; know your table size, production workload, MySQL configuration, MariaDB version, execution plan, lock duration, or deployment strategy.&lt;/p&gt;

&lt;p&gt;It also does not inspect a live database.&lt;/p&gt;

&lt;p&gt;That means:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PASSED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;does not mean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;This migration is guaranteed to be safe in production.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It means that the migration was fully analyzed under the supported static rules and no blocking finding was detected.&lt;/p&gt;

&lt;p&gt;That is a much narrower promise, but it is one I am comfortable making.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I started small
&lt;/h2&gt;

&lt;p&gt;It would be easy to add database connections, configuration files, suppression systems, custom plugins, Git integration, automatic fixes, PostgreSQL support, and many other features.&lt;/p&gt;

&lt;p&gt;I intentionally did not start there.&lt;/p&gt;

&lt;p&gt;For the first version I wanted something that is:&lt;/p&gt;

&lt;p&gt;small, deterministic, CI-friendly, easy to understand, and conservative when it is uncertain.&lt;/p&gt;

&lt;p&gt;The tool currently supports PHP 8.1 through PHP 8.5 and has no Symfony runtime dependency.&lt;/p&gt;

&lt;p&gt;Symfony developers can use it, but it remains a standalone CLI tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The first public release is now available:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
&lt;a href="https://github.com/alkinbg/doctrine-migration-guard" rel="noopener noreferrer"&gt;https://github.com/alkinbg/doctrine-migration-guard&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Packagist:&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
&lt;a href="https://packagist.org/packages/alkinbg/doctrine-migration-guard" rel="noopener noreferrer"&gt;https://packagist.org/packages/alkinbg/doctrine-migration-guard&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Install it with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require &lt;span class="nt"&gt;--dev&lt;/span&gt; alkinbg/doctrine-migration-guard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is still an early &lt;code&gt;0.x&lt;/code&gt; project, so feedback is very welcome — especially real Doctrine migrations where the current rules produce an unexpected result.&lt;/p&gt;

&lt;p&gt;For me, the interesting part is not trying to make migrations magically safe.&lt;/p&gt;

&lt;p&gt;It is adding one more useful review step &lt;strong&gt;before risky database changes reach production&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>php</category>
      <category>devops</category>
      <category>doctrine</category>
    </item>
    <item>
      <title>Preventing lost updates in Symfony APIs with ETags and Doctrine</title>
      <dc:creator>Alkin Veysal</dc:creator>
      <pubDate>Sat, 22 Aug 2026 16:38:18 +0000</pubDate>
      <link>https://dev.to/alkin/preventing-lost-updates-in-symfony-apis-with-etags-and-doctrine-1h57</link>
      <guid>https://dev.to/alkin/preventing-lost-updates-in-symfony-apis-with-etags-and-doctrine-1h57</guid>
      <description>&lt;p&gt;There is a concurrency problem in APIs that is easy to miss.&lt;/p&gt;

&lt;p&gt;Two clients read the same resource:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client A -&amp;gt; GET /documents/42
Client B -&amp;gt; GET /documents/42
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both receive the same version.&lt;/p&gt;

&lt;p&gt;Client A changes the title and saves it.&lt;/p&gt;

&lt;p&gt;Then Client B, still working with the older representation, sends another update.&lt;/p&gt;

&lt;p&gt;Without a concurrency check, the second request can overwrite the first one without knowing that the resource changed in the meantime.&lt;/p&gt;

&lt;p&gt;This is the classic &lt;strong&gt;lost update&lt;/strong&gt; problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doctrine versioning is important, but there is another part
&lt;/h2&gt;

&lt;p&gt;Doctrine supports optimistic locking with a version field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Doctrine\ORM\Mapping&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="no"&gt;ORM&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&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;Document&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: 180)]&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\Version]&lt;/span&gt;
    &lt;span class="na"&gt;#[ORM\Column(type: 'integer')]&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;$version&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;Doctrine's version field already provides the persistence-level optimistic locking mechanism.&lt;/p&gt;

&lt;p&gt;It can also be used directly as the client-side concurrency token. An API could expose the version, have the client send it back with the update, and use that value as Doctrine's expected version. That is a perfectly valid approach, and ETags are not required to prevent lost updates.&lt;/p&gt;

&lt;p&gt;For this bundle, I wanted to keep the persistence version out of the API representation and express the same precondition using standard HTTP semantics instead.&lt;/p&gt;

&lt;p&gt;I want the client to say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Update this resource only if it is still the version I previously received.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;HTTP already has a mechanism for exactly this: &lt;strong&gt;ETag&lt;/strong&gt; and &lt;strong&gt;If-Match&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using an ETag when reading the resource
&lt;/h2&gt;

&lt;p&gt;I built &lt;a href="https://github.com/alkinbg/optimistic-concurrency-bundle" rel="noopener noreferrer"&gt;OptimisticConcurrencyBundle&lt;/a&gt; to connect these HTTP semantics with Doctrine versioned entities.&lt;/p&gt;

&lt;p&gt;A read endpoint can be marked with &lt;code&gt;#[EntityTag]&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;OptimisticConcurrency\Bundle\Attribute\EntityTag&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\HttpFoundation\JsonResponse&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[EntityTag('document', scope: 'document-detail-v1')]&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;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Document&lt;/span&gt; &lt;span class="nv"&gt;$document&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;JsonResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&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;$document&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="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$document&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getTitle&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response receives a strong ETag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt; &lt;span class="ne"&gt;OK&lt;/span&gt;
&lt;span class="na"&gt;ETag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"oc1-..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default validator is based on the entity identity, its Doctrine version and the optional representation scope.&lt;/p&gt;

&lt;p&gt;The actual database ID and version are not exposed directly in the header.&lt;/p&gt;

&lt;h2&gt;
  
  
  Requiring the same version on update
&lt;/h2&gt;

&lt;p&gt;The write endpoint uses &lt;code&gt;#[RequireIfMatch]&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Doctrine\ORM\EntityManagerInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;OptimisticConcurrency\Bundle\Attribute\RequireIfMatch&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\HttpFoundation\JsonResponse&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[RequireIfMatch('document', scope: 'document-detail-v1')]&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;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;Document&lt;/span&gt; &lt;span class="nv"&gt;$document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;EntityManagerInterface&lt;/span&gt; &lt;span class="nv"&gt;$entityManager&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;JsonResponse&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$document&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;rename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'New title'&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&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;$document&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="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$document&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getTitle&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client sends back the ETag it received when reading the resource:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;PATCH /documents/42
If-Match: "oc1-..."
Content-Type: application/json
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the resource still has the same version, the request continues normally.&lt;/p&gt;

&lt;p&gt;If somebody changed it in the meantime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;412&lt;/span&gt; &lt;span class="ne"&gt;Precondition Failed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the client does not send &lt;code&gt;If-Match&lt;/code&gt; at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;428&lt;/span&gt; &lt;span class="ne"&gt;Precondition Required&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Malformed conditional headers are rejected with &lt;code&gt;400 Bad Request&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why there are two checks
&lt;/h2&gt;

&lt;p&gt;This was the part I cared about most when implementing the bundle.&lt;/p&gt;

&lt;p&gt;Checking &lt;code&gt;If-Match&lt;/code&gt; before executing the controller is useful, but it is not enough.&lt;/p&gt;

&lt;p&gt;Imagine this sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. If-Match is checked
2. The version is correct
3. Another request updates the row
4. Our controller calls flush()
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is still a race window between steps 1 and 4.&lt;/p&gt;

&lt;p&gt;For this reason the bundle does not try to replace Doctrine optimistic locking.&lt;/p&gt;

&lt;p&gt;It uses both mechanisms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP If-Match check
        |
        v
controller executes
        |
        v
Doctrine #[ORM\Version] check during flush()
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The HTTP check rejects a request that was already stale when it arrived.&lt;/p&gt;

&lt;p&gt;The Doctrine version check protects the final database write if another update happens after the HTTP check.&lt;/p&gt;

&lt;p&gt;If Doctrine detects an optimistic lock conflict during &lt;code&gt;flush()&lt;/code&gt;, the bundle converts it to &lt;code&gt;412 Precondition Failed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I think keeping both responsibilities separate is important.&lt;/p&gt;

&lt;p&gt;HTTP handles the client's representation.&lt;/p&gt;

&lt;p&gt;Doctrine remains responsible for the atomic database update.&lt;/p&gt;

&lt;h2&gt;
  
  
  ETags represent representations
&lt;/h2&gt;

&lt;p&gt;There is another detail that is easy to overlook.&lt;/p&gt;

&lt;p&gt;An ETag validates a representation, not simply a database row.&lt;/p&gt;

&lt;p&gt;For a simple API, entity identity + version may be enough.&lt;/p&gt;

&lt;p&gt;But imagine that the response also depends on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;locale;&lt;/li&gt;
&lt;li&gt;serializer groups;&lt;/li&gt;
&lt;li&gt;related entities;&lt;/li&gt;
&lt;li&gt;user-specific fields;&lt;/li&gt;
&lt;li&gt;query parameters.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If those values can change without increasing the entity version, the default ETag is no longer enough to describe the complete representation.&lt;/p&gt;

&lt;p&gt;For that reason the bundle supports an explicit &lt;code&gt;scope&lt;/code&gt; and a custom &lt;code&gt;EntityTagProviderInterface&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I prefer making this limitation explicit instead of pretending that one Doctrine version field can describe every possible HTTP representation.&lt;/p&gt;

&lt;h2&gt;
  
  
  A deliberate limitation: hard deletes
&lt;/h2&gt;

&lt;p&gt;There is one case I intentionally don't support: normal Doctrine hard deletes.&lt;/p&gt;

&lt;p&gt;Doctrine's standard &lt;code&gt;DELETE&lt;/code&gt; operation does not include the optimistic-lock version in the SQL &lt;code&gt;WHERE&lt;/code&gt; condition.&lt;/p&gt;

&lt;p&gt;That means an &lt;code&gt;If-Match&lt;/code&gt; check before the controller would still leave a race window before the actual delete.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;#[RequireIfMatch]&lt;/code&gt; rejects HTTP &lt;code&gt;DELETE&lt;/code&gt; requests instead of providing a concurrency guarantee that is not really there.&lt;/p&gt;

&lt;p&gt;A versioned soft delete is different because it goes through Doctrine's version-checked &lt;code&gt;UPDATE&lt;/code&gt; path.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical Symfony UX Turbo example
&lt;/h2&gt;

&lt;p&gt;This problem is not limited to JSON APIs.&lt;/p&gt;

&lt;p&gt;I ran into the same kind of situation in a Symfony application using UX Turbo.&lt;/p&gt;

&lt;p&gt;Imagine an operator opens a reservation page while the entity is at version 12.&lt;/p&gt;

&lt;p&gt;Another operator opens the same reservation and changes its status. Doctrine updates the entity and its version becomes 13.&lt;/p&gt;

&lt;p&gt;The first browser still has the old page open.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser A                        Browser B

GET reservation v12             GET reservation v12
                                 |
                                 change status
                                 |
                                 POST
                                 |
                                 reservation -&amp;gt; v13

old Turbo form still open
|
POST old state
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without a concurrency check, the old form can submit data based on version 12 even though version 13 already exists.&lt;/p&gt;

&lt;p&gt;The important part is that Turbo does not change the concurrency problem. It makes navigation and form submissions nicer, but the server still receives an HTTP request that may have been created from stale state.&lt;/p&gt;

&lt;p&gt;So the same idea can be used.&lt;/p&gt;

&lt;p&gt;When the page is rendered, the current validator is associated with the form. On submission, a small Stimulus integration can send it as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;If-Match: "oc1-..."
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the reservation has not changed, the request continues normally.&lt;/p&gt;

&lt;p&gt;If another operator changed it first, the server responds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;412&lt;/span&gt; &lt;span class="ne"&gt;Precondition Failed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of silently overwriting the newer data, the UI can then tell the user that the reservation changed and should be refreshed.&lt;/p&gt;

&lt;p&gt;For me this is one of the useful properties of using HTTP preconditions for concurrency: the same contract works for an API client and for an interactive Symfony application using Turbo.&lt;/p&gt;

&lt;p&gt;Turbo improves the interaction.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;If-Match&lt;/code&gt; tells the server which version that interaction was based on.&lt;/p&gt;

&lt;p&gt;Doctrine still protects the final database write.&lt;/p&gt;

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

&lt;p&gt;The bundle can be installed with Composer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require alkinbg/optimistic-concurrency-bundle
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It currently supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PHP 8.2+&lt;/li&gt;
&lt;li&gt;Symfony 7.4 LTS and 8.1+&lt;/li&gt;
&lt;li&gt;Doctrine ORM 3.4.4+&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No bundle configuration is required.&lt;/p&gt;

&lt;p&gt;The project is MIT licensed:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/alkinbg/optimistic-concurrency-bundle" rel="noopener noreferrer"&gt;https://github.com/alkinbg/optimistic-concurrency-bundle&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://packagist.org/packages/alkinbg/optimistic-concurrency-bundle" rel="noopener noreferrer"&gt;https://packagist.org/packages/alkinbg/optimistic-concurrency-bundle&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you build Symfony APIs and have dealt with lost updates differently, I'd be interested to hear how you handle it.&lt;/p&gt;

</description>
      <category>php</category>
      <category>symfony</category>
      <category>doctrine</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I made a Symfony bundle for masking sensitive data</title>
      <dc:creator>Alkin Veysal</dc:creator>
      <pubDate>Sat, 22 Aug 2026 10:14:31 +0000</pubDate>
      <link>https://dev.to/alkin/i-made-a-symfony-bundle-for-masking-sensitive-data-3b1f</link>
      <guid>https://dev.to/alkin/i-made-a-symfony-bundle-for-masking-sensitive-data-3b1f</guid>
      <description>&lt;p&gt;I recently released a small open-source Symfony bundle called &lt;strong&gt;MaskedBundle&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The reason for building it was quite simple: logs are useful, but sometimes they can contain values that should not be there.&lt;/p&gt;

&lt;p&gt;I wanted something I could reuse in Symfony projects to mask sensitive values before they reach logs or other diagnostic output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Basic usage
&lt;/h2&gt;

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

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

&lt;/div&gt;



&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Masked\Bundle\SensitiveDataMasker&lt;/span&gt;&lt;span class="p"&gt;;&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;PaymentService&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="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;SensitiveDataMasker&lt;/span&gt; &lt;span class="nv"&gt;$masker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;example&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;masker&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'Card: 4111111111111111'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Card: ████████████████
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the moment automatic detection focuses on payment card numbers.&lt;/p&gt;

&lt;p&gt;I deliberately don't try to automatically detect every possible token, password or secret. There are too many formats and guessing can easily produce false positives.&lt;/p&gt;

&lt;p&gt;Instead, values known by the application can be passed explicitly:&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;$token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'secret-access-token'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$masked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$sensitiveDataMasker&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'Authentication failed for token '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;sensitiveValues&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$token&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;Result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Authentication failed for token ███████████████████
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both approaches can be used together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Arrays and logs
&lt;/h2&gt;

&lt;p&gt;There is also a &lt;code&gt;StructuredDataMasker&lt;/code&gt; for arrays:&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;$masked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$structuredDataMasker&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'customer'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'card'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'4111111111111111'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bundle also has optional Monolog integration, so messages and context can be masked before they are written to the log.&lt;/p&gt;

&lt;p&gt;I kept the Monolog part optional because the masking services can also be useful on their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  One thing I cared about
&lt;/h2&gt;

&lt;p&gt;Because this code handles sensitive data, I didn't want unusual input to result in partially checked data being returned.&lt;/p&gt;

&lt;p&gt;There are limits for things like very large arrays and explicit-value searches. If a detection budget is exceeded, the masking operation prefers to fail closed.&lt;/p&gt;

&lt;p&gt;It adds some complexity internally, but I think it is the safer behaviour for this kind of library.&lt;/p&gt;

&lt;h2&gt;
  
  
  That's it
&lt;/h2&gt;

&lt;p&gt;MaskedBundle currently requires PHP 8.4.1+ and Symfony 8.1+.&lt;/p&gt;

&lt;p&gt;It is MIT licensed:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/alkinbg/masked-bundle" rel="noopener noreferrer"&gt;https://github.com/alkinbg/masked-bundle&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://packagist.org/packages/alkinbg/masked-bundle" rel="noopener noreferrer"&gt;https://packagist.org/packages/alkinbg/masked-bundle&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you use Symfony and have any feedback, I'd be happy to hear it.&lt;/p&gt;

</description>
      <category>php</category>
      <category>symfony</category>
      <category>showdev</category>
      <category>security</category>
    </item>
  </channel>
</rss>
