<?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>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;The version check is important because it makes the database update safe against concurrent changes between loading the entity and flushing it.&lt;/p&gt;

&lt;p&gt;But for an HTTP API, I also 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>
