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