<?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: Arnaud POINTET</title>
    <description>The latest articles on DEV Community by Arnaud POINTET (@oipnet).</description>
    <link>https://dev.to/oipnet</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%2F320559%2Fb68a6be2-7bc6-40a1-b192-e309933bd3f7.jpeg</url>
      <title>DEV Community: Arnaud POINTET</title>
      <link>https://dev.to/oipnet</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/oipnet"/>
    <language>en</language>
    <item>
      <title>Encrypting columns with Symfony 8.2: what #[Encrypted] protects, and what it doesn't</title>
      <dc:creator>Arnaud POINTET</dc:creator>
      <pubDate>Fri, 30 Oct 2026 07:47:49 +0000</pubDate>
      <link>https://dev.to/oipnet/encrypting-columns-with-symfony-82-what-encrypted-protects-and-what-it-doesnt-5b3o</link>
      <guid>https://dev.to/oipnet/encrypting-columns-with-symfony-82-what-encrypted-protects-and-what-it-doesnt-5b3o</guid>
      <description>&lt;p&gt;Symfony 8.2 brings a new component, KeyManagement, still experimental. Its Doctrine bridge encrypts a column with an attribute, and an encrypted column can even stay searchable thanks to a blind index.&lt;/p&gt;

&lt;p&gt;It takes very little code. But encrypting a column is not a box to tick: you need to know what you are protecting against, accept what the database will no longer be able to do, and plan for the day you encrypt a column that already holds data. This article works through those questions, using a table of patients as the example.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two attributes
&lt;/h2&gt;

&lt;p&gt;Encrypting a patient's phone number and date of birth comes down to two lines, one per column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\KeyManagement\Bridge\DoctrineOrm\Attribute\Encrypted&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[ORM\Column(nullable: true)]&lt;/span&gt;
&lt;span class="na"&gt;#[Encrypted(key: 'patient.contact')]&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;$phone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[ORM\Column(type: Types::DATE\_IMMUTABLE, nullable: true)]&lt;/span&gt;
&lt;span class="na"&gt;#[Encrypted(key: 'patient.identity')]&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="nc"&gt;\DateTimeImmutable&lt;/span&gt; &lt;span class="nv"&gt;$birthDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Doctrine writes an encrypted value to the database, and decrypts it when it reads the entity back. The property keeps its PHP type: the column's Doctrine type converts the value first (a date becomes text), then the result is encrypted. There is no Doctrine type to write and no listener to register: when the container is compiled, Symfony finds the marked properties and declares the types they need.&lt;/p&gt;

&lt;p&gt;Encryption is &lt;strong&gt;randomized&lt;/strong&gt; : the same value, encrypted twice, gives two different results. That is deliberate, otherwise comparing the column would be enough to tell which patients share a number. The flip side is that &lt;code&gt;WHERE email = ?&lt;/code&gt; no longer finds anything. To find a patient by their encrypted email address, you add a &lt;strong&gt;blind index&lt;/strong&gt; :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[ORM&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nd"&gt;Column]&lt;/span&gt;
&lt;span class="nd"&gt;#[Encrypted(key:&lt;/span&gt; &lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="nd"&gt;patient&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nd"&gt;contact&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="nd"&gt;)]&lt;/span&gt;
&lt;span class="n"&gt;private&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;#[ORM&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nd"&gt;Column(length:&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="nd"&gt;)]&lt;/span&gt;
&lt;span class="nd"&gt;#[BlindIndexed(&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="nd"&gt;email&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="nd"&gt;,&lt;/span&gt; &lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="nd"&gt;patient&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="nd"&gt;email&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="nd"&gt;)]&lt;/span&gt;
&lt;span class="n"&gt;private&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;emailIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&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 &lt;code&gt;emailIndex&lt;/code&gt; column holds a keyed digest of the address: an HMAC-SHA256, computed under a subkey of its own, derived from the index's key and its name, &lt;code&gt;patient-email&lt;/code&gt;. The same address always gives the same digest, but you need the key to compute it, and two indexes never give the same digest for the same value. A listener recomputes it on every &lt;code&gt;flush()&lt;/code&gt; that inserts or updates the entity, and the lookup compares digests:&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;findOneBy&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'emailIndex'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;)]);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What it protects against
&lt;/h2&gt;

&lt;p&gt;Before encrypting anything, ask who you want to stop from reading. Column encryption protects against &lt;strong&gt;people who read the database without going through the application&lt;/strong&gt; :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a backup left on poorly protected storage;&lt;/li&gt;
&lt;li&gt;a copy of the production database on a developer's laptop, or in a staging environment;&lt;/li&gt;
&lt;li&gt;an SQL injection that dumps the table: it gets encrypted bytes, not phone numbers;&lt;/li&gt;
&lt;li&gt;read access to the database granted for support or reporting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It does not protect against &lt;strong&gt;the application itself&lt;/strong&gt;. The application has access to the keys, since it decrypts every time it loads an entity, and holds them in memory for the duration of a request. Someone who takes over the application server, or gets it to run their code, reads the data in clear, just as the application does. And once decrypted, a value is a string like any other: it ends up in the logs if you log the entity, in Sentry with an exception's stack trace, in a cache, in a CSV export or in a search engine. Encryption stops at the database; vigilance has to go further.&lt;/p&gt;

&lt;p&gt;That leaves the question of the key. The component uses &lt;strong&gt;envelope encryption&lt;/strong&gt; : each value is encrypted with a data key, which is itself encrypted with a master key that stays in a KMS (AWS KMS, Google Cloud KMS, Vault…). Without any particular configuration, each value carries its own encrypted data key, and every read and every write calls the KMS. With a &lt;strong&gt;key store&lt;/strong&gt; (&lt;code&gt;key_management.store&lt;/code&gt;), the encrypted data keys are kept in a table of the database: all the values of a scope (&lt;code&gt;patient.contact&lt;/code&gt;) share one key, and the KMS is called once per key and per process. Without the master key, those stored keys are useless.&lt;/p&gt;

&lt;p&gt;So where the master key lives decides how much protection you actually get. If the master key sits in the &lt;code&gt;.env&lt;/code&gt; file, on the same server that also runs the database backups, someone who gets the whole server gets both. A local KMS with its key in the &lt;code&gt;.env&lt;/code&gt; file is fine for development; in production, the master key lives somewhere other than the database, and ideally never leaves the KMS.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the database can no longer do
&lt;/h2&gt;

&lt;p&gt;An encrypted column is an opaque binary column. The database can no longer do anything with it, and that goes beyond &lt;code&gt;WHERE&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No sorting, no partial search.&lt;/strong&gt; No &lt;code&gt;ORDER BY&lt;/code&gt;, no &lt;code&gt;LIKE&lt;/code&gt;, no date comparison. That is why, in our patient table, the name stays in clear: the list is sorted by name, in SQL. Only encrypt what needs to be, and keep in mind that an encrypted date of birth no longer lets you look for "patients born before 1950".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No uniqueness constraint.&lt;/strong&gt; Two identical addresses give two different encrypted values, so a &lt;code&gt;UNIQUE&lt;/code&gt; index on the encrypted column never rejects anything. Put it on the blind index column instead: two identical addresses have the same digest there, as far as the projection is concerned (see below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No length.&lt;/strong&gt; The type declares an unbounded binary column (&lt;code&gt;LONGBLOB&lt;/code&gt;, &lt;code&gt;BYTEA&lt;/code&gt;…), whatever &lt;code&gt;length&lt;/code&gt; the mapping says. That &lt;code&gt;length&lt;/code&gt; describes the plaintext, and the encrypted result is longer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A value is not bound to its row.&lt;/strong&gt; The encryption does not know which row it is writing to. Someone who can write to the database can therefore copy one patient's encrypted phone number into the same column of another patient, and it will decrypt without error. If that risk matters to you, bind the value to its row yourself, for example by including the row's identifier in it and checking it after reading.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the blind index reveals
&lt;/h2&gt;

&lt;p&gt;A blind index does not reveal the addresses, but it does reveal something encryption was hiding: &lt;strong&gt;which rows share the same value&lt;/strong&gt; , and how often each value occurs. For an email address, that is almost nothing, since nearly all addresses are different. For a column with few possible values, it is much worse: a blind index on country of birth or blood type shows the distribution at a glance, and knowing the value of a single row is enough to know the value of every row that carries the same digest. So only index varied values that are looked up by equality: an email address, a file number, a social security number.&lt;/p&gt;

&lt;p&gt;Two other points are specific to the blind index.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Its key never changes.&lt;/strong&gt; With a key store, the data key of an encrypted column is renewed on its own: after thirty days, the next write creates a new one, and the old ones stay readable. This is not hygiene: each value is encrypted with a random 96-bit nonce, and the standard (NIST SP 800-38D) caps a key at 2^32 encryptions in that case. The index key, on the other hand, must never be renewed: every digest already written was computed with it, and a new key would no longer find anyone. Changing that key means recomputing the whole column.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The projection decides what counts as equal.&lt;/strong&gt; For an email address, you pick the &lt;code&gt;Email&lt;/code&gt; projection, which normalizes the domain (its case, and the ASCII form of an internationalized domain) and ignores whitespace around the address: &lt;code&gt;ada@Example.ORG&lt;/code&gt; and &lt;code&gt;ada@example.org&lt;/code&gt; give the same digest. It leaves the part before the &lt;code&gt;@&lt;/code&gt; alone, since the standard says it is case-sensitive. If your application treats &lt;code&gt;Ada@example.org&lt;/code&gt; and &lt;code&gt;ada@example.org&lt;/code&gt; as the same person, normalize the address before saving it: the index compares what it is given.&lt;/p&gt;

&lt;h2&gt;
  
  
  Encrypting a column that already exists
&lt;/h2&gt;

&lt;p&gt;On an empty database, adding the attribute is all it takes. In a real application, the phone column already holds thousands of numbers in clear, and that is where it gets tricky.&lt;/p&gt;

&lt;p&gt;Adding &lt;code&gt;#[Encrypted]&lt;/code&gt; changes the column's type: the migration Doctrine generates turns it into a binary column, but encrypts nothing. On PostgreSQL, it even fails as generated: you need to add &lt;code&gt;USING phone::bytea&lt;/code&gt; (and &lt;code&gt;USING birth_date::text::bytea&lt;/code&gt; for a date) for the database to accept the conversion. The numbers stay in clear, and on the first read, Doctrine rejects what it finds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Doctrine\DBAL\Types\Exception\ValueNotConvertible:
Stored value is not a valid KeyManagement envelope.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every row that is not encrypted makes loading its entity fail. And you cannot encrypt through the ORM: it cannot load those entities, and even if it could, it would see unchanged values and write nothing. You have to go through DBAL, asking Doctrine for the name of the type Symfony declared for the column:&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\DBAL\ParameterType&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;Doctrine\DBAL\Types\Type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getClassMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Patient&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$phone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;getType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$metadata&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getTypeOfField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'phone'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nv"&gt;$connection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getConnection&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$platform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$connection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getDatabasePlatform&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$connection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;iterateAssociative&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SELECT id, phone FROM patient ORDER BY id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$connection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'patient'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'phone'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$phone&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;convertToDatabaseValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'phone'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$platform&lt;/span&gt;&lt;span class="p"&gt;)],&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;$row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'phone'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;ParameterType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;BINARY&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;getTypeOfField()&lt;/code&gt; returns the name of the type Symfony declared for that column (here &lt;code&gt;encrypted_string@patient.contact&lt;/code&gt;), and that type encrypts exactly the way the application will. For a date, first rebuild the &lt;code&gt;DateTimeImmutable&lt;/code&gt; from the stored string, for example with &lt;code&gt;Type::getType('date_immutable')-&amp;gt;convertToPHPValue()&lt;/code&gt;, then pass it to &lt;code&gt;convertToDatabaseValue()&lt;/code&gt;, as the ORM would.&lt;/p&gt;

&lt;p&gt;As written, this script is not idempotent: run again, it would encrypt the already encrypted rows a second time. To make it idempotent, skip the rows that &lt;code&gt;Envelope::fromBytes()&lt;/code&gt; accepts: a plaintext value is never a valid envelope, and the method throws an exception on it. Finally, between the schema migration and the end of the script, every row that is not yet encrypted is unreadable. There are two ways to handle that.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A short maintenance window.&lt;/strong&gt; Run the schema migration and the script back to back, with the application stopped. It is the simplest option. With a key store, where the KMS is called once per scope, a few hundred thousand rows are encrypted in a few minutes. Without one, each row costs a KMS call, which is slow and billed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A parallel column.&lt;/strong&gt; Add an encrypted column next to the old one; the application writes to both and reads the old one. A script fills the new one in batches, only touching the rows where it is empty. Then switch reads over, and drop the old column. It takes longer, but nothing stops.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Either way, don't forget the data that is not in the table: backups taken before the migration still hold the numbers in clear, and will for as long as they are kept.&lt;/p&gt;

&lt;p&gt;The blind index has the same problem. The listener only computes a digest for the entities a &lt;code&gt;flush()&lt;/code&gt; inserts or updates: rows that existed before the column was added keep an empty digest until they are modified, and no search finds them until then. So fill them once, with the same kind of script: read the address back with the encrypted type's &lt;code&gt;convertToPHPValue()&lt;/code&gt;, then write &lt;code&gt;$index-&amp;gt;of()&lt;/code&gt; into the column. The same trap awaits any bulk &lt;code&gt;UPDATE&lt;/code&gt;, in SQL or in DQL: it changes the address without changing the digest, and the search then returns a row that no longer has that address.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I take away from it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;#[Encrypted]&lt;/code&gt; handles the most technical part: sound encryption, data keys renewed on their own, a master key you can change without rewriting rows, and even move to another provider when the data keys are kept in a store. Writing all that by hand, with a home-made Doctrine type, was a source of mistakes.&lt;/p&gt;

&lt;p&gt;But column encryption is still a design decision. It protects the database, not the application; it takes sorting, searching and uniqueness away from the database; and the blind index, which makes a column searchable again, reveals what encryption was hiding. Before adding the attribute, it is worth writing down who you are protecting against, where the master key lives, and how you will encrypt the data that is already there.&lt;/p&gt;

&lt;p&gt;To try it without installing anything, two Practice exercises run in your browser: &lt;a href="https://apprendre.forelse.fr/en/practice/symfony-8-2-colonnes-chiffrees?utm_source=rss&amp;amp;utm_medium=feed&amp;amp;utm_campaign=encrypting-doctrine-columns" rel="noopener noreferrer"&gt;encrypted columns&lt;/a&gt; and &lt;a href="https://apprendre.forelse.fr/en/practice/symfony-8-2-index-aveugle?utm_source=rss&amp;amp;utm_medium=feed&amp;amp;utm_campaign=encrypting-doctrine-columns" rel="noopener noreferrer"&gt;blind index&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>php</category>
      <category>symfony</category>
      <category>security</category>
      <category>doctrine</category>
    </item>
    <item>
      <title>Sharing a Symfony project in a URL, without storing anything</title>
      <dc:creator>Arnaud POINTET</dc:creator>
      <pubDate>Fri, 30 Oct 2026 07:30:42 +0000</pubDate>
      <link>https://dev.to/oipnet/sharing-a-symfony-project-in-a-url-without-storing-anything-1ohe</link>
      <guid>https://dev.to/oipnet/sharing-a-symfony-project-in-a-url-without-storing-anything-1ohe</guid>
      <description>&lt;p&gt;"Can you show me your code?" On Slack, the answer is usually a forty-line paste, without the Twig template that goes with it, without the route, and with no way to run it. Whoever wants to help copies it all into a local project, notices a file is missing, and the conversation fizzles out.&lt;/p&gt;

&lt;p&gt;I added a &lt;a href="https://apprendre.forelse.fr/en/playground?utm_source=rss&amp;amp;utm_medium=feed&amp;amp;utm_campaign=sharing-a-php-project-in-a-url" rel="noopener noreferrer"&gt;playground&lt;/a&gt; to &lt;a href="https://apprendre.forelse.fr?utm_source=rss&amp;amp;utm_medium=feed&amp;amp;utm_campaign=sharing-a-php-project-in-a-url" rel="noopener noreferrer"&gt;apprendre.forelse.fr&lt;/a&gt;: a blank Symfony 8 or Laravel 13 project that runs in the browser. You edit a controller, the preview updates, and a "Share" button gives you a link. Whoever opens it gets the project as it is, ready to run.&lt;/p&gt;

&lt;p&gt;The interesting part isn't the button, it's what isn't behind it: &lt;strong&gt;no database, no storage, no share ID&lt;/strong&gt;. All the code travels in the URL. Here is how, and the two questions it raises: size, and security.&lt;/p&gt;

&lt;h2&gt;
  
  
  The project already runs in the browser
&lt;/h2&gt;

&lt;p&gt;I described the mechanics in &lt;a href="https://apprendre.forelse.fr/en/blog/running-symfony-in-the-browser?utm_source=rss&amp;amp;utm_medium=feed&amp;amp;utm_campaign=sharing-a-php-project-in-a-url" rel="noopener noreferrer"&gt;a previous article&lt;/a&gt;: PHP 8.4 compiled to WebAssembly, a real Symfony project with its &lt;code&gt;vendor/&lt;/code&gt;, and a Service Worker serving the preview. The server only delivers static files.&lt;/p&gt;

&lt;p&gt;The playground reuses exactly that machinery. To the platform, it's an exercise with no objective and no test: a starting project, a list of editable files, and that's it. The editor, the console (&lt;code&gt;bin/console&lt;/code&gt;, &lt;code&gt;php artisan&lt;/code&gt;) and the preview are the same as in the exercises.&lt;/p&gt;

&lt;p&gt;That left one question: how do you hand your copy of the project to someone else?&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not server-side storage
&lt;/h2&gt;

&lt;p&gt;The classic answer, the one 3v4l and CodePen use, is to save the code and hand out a short ID: &lt;code&gt;/s/a8Fk2&lt;/code&gt;. It's clean, but it has a cost:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a table that grows forever, full of content nobody moderates;&lt;/li&gt;
&lt;li&gt;a GDPR question with every share, since the code can contain anything;&lt;/li&gt;
&lt;li&gt;links that die the day you clean up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a learning playground, I wanted none of that. And the browser offers a perfect place to carry data without sending it to the server: &lt;strong&gt;the URL fragment&lt;/strong&gt; , everything after the &lt;code&gt;#&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fragment, the part of the URL the server never sees
&lt;/h2&gt;

&lt;p&gt;When you open &lt;code&gt;https://apprendre.forelse.fr/en/playground/symfony#code=…&lt;/code&gt;, the browser asks the server for &lt;code&gt;/en/playground/symfony&lt;/code&gt; and keeps the rest to itself. The fragment shows up neither in the HTTP request, nor in the server logs, nor in the &lt;code&gt;Referer&lt;/code&gt; header. Only the page's JavaScript can read it, through &lt;code&gt;location.hash&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's exactly what's needed: the public page stays the same for everyone (one URL to index, one canonical), and the code only exists in the link and in the browsers of the people who open it.&lt;/p&gt;

&lt;p&gt;One exception to watch: analytics. Some trackers send the full URL, fragment included. The site uses Umami, which has an option for that (tag simplified):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;defer&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/mesure/traceur.js"&lt;/span&gt; &lt;span class="na"&gt;data-website-id=&lt;/span&gt;&lt;span class="s"&gt;"…"&lt;/span&gt; &lt;span class="na"&gt;data-exclude-hash=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without it, every visitor's code would have ended up in the stats.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fitting a project in a link
&lt;/h2&gt;

&lt;p&gt;A full Symfony project weighs several megabytes, &lt;code&gt;vendor/&lt;/code&gt; included. But everything that doesn't change is already known: it's the starting project, the same for everyone. So the link only carries the difference, that is &lt;strong&gt;the files changed or created&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;changedFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;starting&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;starting&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;content&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;Those files are turned into JSON, compressed with deflate (the &lt;a href="https://github.com/101arrowz/fflate" rel="noopener noreferrer"&gt;fflate&lt;/a&gt; library, already there to unpack the PHP environment), then encoded in base64url, the base64 variant that only uses URL-safe characters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;encodeFragment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;deflateSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;strToU8&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;v&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="nx"&gt;files&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;binary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mh"&gt;0x8000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;binary&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mh"&gt;0x8000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;code=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;binary&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\+&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s1"&gt;_&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/=+$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;v: 1&lt;/code&gt; costs a few bytes, but it will let the format change without breaking links already shared.&lt;/p&gt;

&lt;p&gt;In practice, it's very reasonable. The starting project's controller and Twig template, slightly edited, come to about 1,500 characters. Compressed and encoded, the fragment is a little over 1,000 characters. PHP code compresses well: lots of repeated keywords, &lt;code&gt;use&lt;/code&gt; statements and indentation.&lt;/p&gt;

&lt;p&gt;One unexpected constraint came from Bluesky, which counts the whole URL in its 300 characters. A shared project quickly goes over: in that case the Bluesky button disappears, and you still have copy link, the phone's share sheet, and LinkedIn and X, which display a shortened address.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading a link you didn't write
&lt;/h2&gt;

&lt;p&gt;Decoding needs more care, because anyone can craft a link. Three rules:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cap the size, before and after decompression.&lt;/strong&gt; A few kilobytes of deflate can inflate to hundreds of megabytes. The fragment is rejected beyond 200,000 characters, and decompression runs chunk by chunk, stopped as soon as the total exceeds 1 MB:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inflate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Inflate&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_BYTES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RangeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;too-large&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;inflate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Reject paths that leave the project.&lt;/strong&gt; A file named &lt;code&gt;../../etc/passwd&lt;/code&gt; or &lt;code&gt;/root.php&lt;/code&gt; is ignored. It would be fairly harmless in PHP's in-memory file system, but there is no reason to accept it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Only accept editable files.&lt;/strong&gt; The link can only write where the visitor could write themselves: for Symfony, &lt;code&gt;src/*/*.php&lt;/code&gt; and the Twig templates; for Laravel, the routes, the code under &lt;code&gt;app/&lt;/code&gt; and the Blade views. The framework configuration isn't part of it: a shared link has no business touching &lt;code&gt;config/packages/security.yaml&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On opening, the link's code becomes the visitor's draft (replacing any they had left), and the fragment is removed from the address bar with &lt;code&gt;history.replaceState&lt;/code&gt;. Otherwise, reloading the page would force the link's code back over the changes made since.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running someone else's code
&lt;/h2&gt;

&lt;p&gt;That was the first question I got: is it really reasonable to send someone a link that runs code in their browser?&lt;/p&gt;

&lt;p&gt;Let's start with what's safe. PHP runs in a Web Worker, in WebAssembly, with no network access: the worker's Content Security Policy only allows fetching the environments and the site's own build files. It can't read the visitor's files or call an API. The HTML and JavaScript the project produces are shown in an iframe served from &lt;strong&gt;another origin&lt;/strong&gt; , with no cookie and no access to the platform. It's the same isolation as for the exercises, where learners already run their own code.&lt;/p&gt;

&lt;p&gt;But "isolated" doesn't mean "harmless". The page shown in the preview can look like anything: a fake login page, for instance. And an infinite loop can make the CPU spin.&lt;/p&gt;

&lt;p&gt;So I chose to &lt;strong&gt;never automatically run code received through a link&lt;/strong&gt;. The project loads and the code can be read in the editor, but the preview first shows a warning:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This link contains code written by someone else. Read it in the editor before running it. It runs isolated from this site, but the page it shows can imitate anything: never type a password into it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The preview only starts when you click "Run this code". That one click is deliberate friction, and the right trade-off: someone sharing a bug wants it read, not run blindly.&lt;/p&gt;

&lt;p&gt;One last security choice: the playground offers Symfony and Laravel, but not Nuxt, even though the platform can simulate it. In the Nuxt simulator, part of the code (the &lt;code&gt;server/&lt;/code&gt; routes) runs on the platform's origin. That's fine for your own code, not for a stranger's.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I take away
&lt;/h2&gt;

&lt;p&gt;The URL fragment is an underrated tool. It lets you share a complete state with no backend, no account and nothing kept, as long as the state stays reasonably small. For a code playground, that's exactly the case: you almost never share more than a few files.&lt;/p&gt;

&lt;p&gt;The flip side is that a link becomes untrusted input like any other. Treat it like a form sent by a stranger: cap, filter, and ask before running.&lt;/p&gt;

&lt;p&gt;You can try the &lt;a href="https://apprendre.forelse.fr/en/playground/symfony?utm_source=rss&amp;amp;utm_medium=feed&amp;amp;utm_campaign=sharing-a-php-project-in-a-url" rel="noopener noreferrer"&gt;Symfony playground&lt;/a&gt; or the &lt;a href="https://apprendre.forelse.fr/en/playground/laravel?utm_source=rss&amp;amp;utm_medium=feed&amp;amp;utm_campaign=sharing-a-php-project-in-a-url" rel="noopener noreferrer"&gt;Laravel playground&lt;/a&gt;, no account needed. And next time someone asks for your code on Slack, send the link.&lt;/p&gt;

&lt;p&gt;The engine is open source (AGPL-3.0): the code from this article is on &lt;a href="https://github.com/Oipnet/apprendre-forelse" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, in &lt;code&gt;playground/src/app/scratch.ts&lt;/code&gt; and &lt;code&gt;Playground.ts&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>php</category>
      <category>symfony</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>400 or 422? Designing validation errors for a Symfony API</title>
      <dc:creator>Arnaud POINTET</dc:creator>
      <pubDate>Wed, 28 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/oipnet/400-or-422-designing-validation-errors-for-a-symfony-api-2lp6</link>
      <guid>https://dev.to/oipnet/400-or-422-designing-validation-errors-for-a-symfony-api-2lp6</guid>
      <description>&lt;p&gt;Since Symfony 8.2, an uncaught &lt;code&gt;ValidationFailedException&lt;/code&gt; no longer results in a 500 error: Symfony itself turns it into a 422 response, complete with the list of violations. The &lt;a href="https://apprendre.forelse.fr/en/practice/symfony-8-2-validation-422?utm_source=rss&amp;amp;utm_medium=feed&amp;amp;utm_campaign=api-validation-errors-400-or-422" rel="noopener noreferrer"&gt;Practice exercise&lt;/a&gt; that showcases this change is mostly about deleting code.&lt;/p&gt;

&lt;p&gt;But behind that deleted line lies a real design question, one I often see poorly settled in the APIs I audit: &lt;strong&gt;what should an API return when the request is invalid?&lt;/strong&gt; Which HTTP status code, which format, and what information does the client need in order to react? This article starts from the exercise and works through the whole question.&lt;/p&gt;

&lt;h2&gt;
  
  
  The exercise: one line fewer
&lt;/h2&gt;

&lt;p&gt;A registration controller validated an object by hand, then had to wrap the validation exception in an HTTP exception:&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;$violations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$validator&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$registration&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;\count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$violations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UnprocessableEntityHttpException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'Invalid registration.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ValidationFailedException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$registration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$violations&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;Without that wrapper, the exception turned into a 500 error. Since Symfony 8.2, you just throw it:&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="nf"&gt;\count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$violations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ValidationFailedException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$registration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$violations&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 is then exactly the one &lt;a href="https://apprendre.forelse.fr/notions/maprequestpayload?utm_source=rss&amp;amp;utm_medium=feed&amp;amp;utm_campaign=api-validation-errors-400-or-422" rel="noopener noreferrer"&gt;&lt;code&gt;#[MapRequestPayload]&lt;/code&gt;&lt;/a&gt; produces when validation fails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://symfony.com/errors/validation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Validation Failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email: This value is not a valid email address.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;username: This value is too short. It should have 2 characters or more."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"violations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"propertyPath"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"This value is not a valid email address."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"template"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"This value is not a valid email address."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"parameters"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"{{ value }}"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;not-an-email&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"urn:uuid:bd79c0ab-ddba-46cc-a703-a7a4b08de310"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&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;(Only the first violation is shown here; the one for the username follows the same pattern.)&lt;/p&gt;

&lt;p&gt;Before going further, let's look at how Symfony gets there. You'll be able to use the same mechanism for your own exceptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Symfony picks the HTTP status code for an exception
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;ValidationFailedException&lt;/code&gt; class now carries an attribute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[WithHttpStatus(&lt;/span&gt;&lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="nd"&gt;)]&lt;/span&gt;
&lt;span class="n"&gt;class&lt;/span&gt; &lt;span class="n"&gt;ValidationFailedException&lt;/span&gt; &lt;span class="n"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;RuntimeException&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When an exception bubbles up to the kernel, Symfony's &lt;code&gt;ErrorListener&lt;/code&gt; looks for this attribute (parent classes included). If it finds it, it wraps the exception in an &lt;code&gt;HttpException&lt;/code&gt; with the right status code: exactly what the controller used to do by hand. Then, if the Serializer component is installed and the client expects JSON (an &lt;code&gt;Accept: application/json&lt;/code&gt; header, or the &lt;code&gt;json&lt;/code&gt; format on the route), the Serializer formats the error: its &lt;code&gt;ProblemNormalizer&lt;/code&gt; notices that the original exception is a validation error and delegates the list of violations to the &lt;code&gt;ConstraintViolationListNormalizer&lt;/code&gt;. Otherwise, the 422 is rendered as HTML, which is the first thing to check when "it doesn't work" with a plain &lt;code&gt;curl&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The same attribute works for your domain exceptions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[WithHttpStatus(&lt;/span&gt;&lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="nd"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="n"&gt;class&lt;/span&gt; &lt;span class="n"&gt;UsernameAlreadyTaken&lt;/span&gt; &lt;span class="n"&gt;extends&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="n"&gt;DomainException&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;One detail tends to reassure hexagonal architecture purists: a PHP attribute creates no runtime dependency. As long as nobody reads the attributes through reflection, the &lt;code&gt;WithHttpStatus&lt;/code&gt; class isn't even loaded. Your domain can carry the attribute without actually depending on HttpKernel. If you'd rather not write anything in the domain, configuration does the same thing from the outside:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/packages/framework.yaml&lt;/span&gt;
&lt;span class="na"&gt;framework&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;exceptions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;App\Domain\UsernameAlreadyTaken&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;status\_code&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;409&lt;/span&gt;
            &lt;span class="na"&gt;log\_level&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;info&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  400, 422, 409: a status code for each problem
&lt;/h2&gt;

&lt;p&gt;The HTTP status code is the first thing a client reads, and often the only thing it reads automatically. So it has to say &lt;strong&gt;who needs to fix what&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;400 Bad Request&lt;/strong&gt; : the request can't be parsed, for instance malformed JSON. The client has a bug: a human has to fix the code that sends the request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;415 Unsupported Media Type&lt;/strong&gt; : the &lt;code&gt;Content-Type&lt;/code&gt; declares a format the API doesn't read. Same consequence: it's the client code that needs fixing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;422 Unprocessable Content&lt;/strong&gt; : the request is readable and well formed, but its content is invalid. The email address isn't one, the username is too short. Often it's the end user who has to correct their input, and the client has to display the errors next to the right fields.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;409 Conflict&lt;/strong&gt; : the request is valid, but it conflicts with the current state of the resource. The username is already taken, the order has already shipped, the version being edited is no longer the latest.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;422 comes from WebDAV, but it has been part of general HTTP semantics since RFC 9110. There's no reason to avoid it anymore.&lt;/p&gt;

&lt;p&gt;Symfony already follows this distinction with &lt;code&gt;#[MapRequestPayload]&lt;/code&gt;: an unsupported format gives a 415 and an unreadable JSON body a 400, whereas a field of the wrong type (a string where an integer is expected) or a failed constraint gives a 422, with the violations.&lt;/p&gt;

&lt;h3&gt;
  
  
  The username-already-taken case
&lt;/h3&gt;

&lt;p&gt;"This username is already taken": 409 or 422? Both are defensible. I lean towards &lt;strong&gt;422 with a violation on &lt;code&gt;username&lt;/code&gt;&lt;/strong&gt; , for a practical reason: the client displays this error exactly like the others, under the relevant field. That's what the &lt;code&gt;UniqueEntity&lt;/code&gt; constraint from Symfony's Doctrine bridge does.&lt;/p&gt;

&lt;p&gt;But then you have to think about the case the constraint can't see. Two simultaneous registrations with the same username both pass validation, then the second one hits the database's unique index. Left unhandled, that's a Doctrine &lt;code&gt;UniqueConstraintViolationException&lt;/code&gt;, and therefore a &lt;strong&gt;500&lt;/strong&gt;. I convert it into a violation, so the client gets the same response in every case:&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;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;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="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UniqueConstraintViolationException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ValidationFailedException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$registration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ConstraintViolationList&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ConstraintViolation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'This username is already taken.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'This username is already taken.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;[],&lt;/span&gt;
            &lt;span class="nv"&gt;$registration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'username'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;$registration&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;UniqueEntity&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;NOT\_UNIQUE\_ERROR&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 &lt;code&gt;UniqueEntity::NOT_UNIQUE_ERROR&lt;/code&gt; code is not a detail: without it, the violation goes out with no &lt;code&gt;type&lt;/code&gt; member, and the client loses the only stable marker it has (see below). And nothing else in that &lt;code&gt;catch&lt;/code&gt;: after this exception, Doctrine has closed the &lt;code&gt;EntityManager&lt;/code&gt;, so a second &lt;code&gt;flush()&lt;/code&gt; would fail.&lt;/p&gt;

&lt;p&gt;With Symfony 8.2, this conversion boils down to a single &lt;code&gt;throw&lt;/code&gt;, with no &lt;code&gt;HttpException&lt;/code&gt; around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The format: Problem Details
&lt;/h2&gt;

&lt;p&gt;The JSON produced by Symfony follows the &lt;strong&gt;Problem Details for HTTP APIs&lt;/strong&gt; format, standardized by RFC 9457 (which superseded RFC 7807). The format defines five standard members:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;type&lt;/code&gt;: a URI that identifies the &lt;strong&gt;kind&lt;/strong&gt; of problem. That's what the client should check, not the title;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;title&lt;/code&gt;: a human-readable summary of that kind of problem, identical from one occurrence to the next;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;status&lt;/code&gt;: the HTTP status code, repeated in the body;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;detail&lt;/code&gt;: the explanation for &lt;strong&gt;this&lt;/strong&gt; occurrence;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;instance&lt;/code&gt;: a URI that identifies this specific occurrence, handy for tracking an error down in the logs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything else, such as &lt;code&gt;violations&lt;/code&gt;, is an extension member, which the standard explicitly provides for. Adopting this format lets clients use existing libraries rather than learn yet another home-grown format. The RFC also recommends the &lt;code&gt;application/problem+json&lt;/code&gt; media type, which some clients use to detect an error. Symfony sets it automatically as soon as the body has a &lt;code&gt;type&lt;/code&gt;, a &lt;code&gt;title&lt;/code&gt; and a &lt;code&gt;status&lt;/code&gt;; a hand-built error response has to remember to do so.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the client actually needs
&lt;/h2&gt;

&lt;p&gt;This is where most APIs fall short. They return errors written for a human reading the response, when it's a &lt;strong&gt;program&lt;/strong&gt; that receives them.&lt;/p&gt;

&lt;h3&gt;
  
  
  A path that matches the request
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;propertyPath&lt;/code&gt; must point to the field as the client sent it. If your API receives &lt;code&gt;snake_case&lt;/code&gt; and your DTOs use &lt;code&gt;camelCase&lt;/code&gt;, the normalizer applies the &lt;em&gt;name converter&lt;/em&gt; configured in the Serializer. The path becomes &lt;code&gt;date_of_birth&lt;/code&gt; rather than &lt;code&gt;dateOfBirth&lt;/code&gt;, provided that converter is properly configured. For a nested structure, the path takes the form &lt;code&gt;addresses[0].postalCode&lt;/code&gt;, which lets the client find the offending row in a repeated form.&lt;/p&gt;

&lt;h3&gt;
  
  
  A stable code, not a message
&lt;/h3&gt;

&lt;p&gt;A violation's &lt;code&gt;title&lt;/code&gt; is a translated message: it changes with the language, and on the day someone fixes a typo in a translation file. A client that checks &lt;code&gt;title === "This value is too short..."&lt;/code&gt; will break sooner or later.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;type&lt;/code&gt; member of each violation, on the other hand, is stable: &lt;code&gt;urn:uuid:&lt;/code&gt; followed by the constraint's code. For example, &lt;code&gt;Length::TOO_SHORT_ERROR&lt;/code&gt; and &lt;code&gt;Email::INVALID_FORMAT_ERROR&lt;/code&gt; are UUIDs set in stone in Symfony's code. Yours should be too, if you write custom constraints: give them error constants with a UUID. That code is what the client should check to display its own message or trigger specific behavior.&lt;/p&gt;

&lt;p&gt;Document these codes alongside your API (in your OpenAPI schema, for example). It's the most useful part of an error contract, and the one most often forgotten.&lt;/p&gt;

&lt;h3&gt;
  
  
  The useful data, and nothing more
&lt;/h3&gt;

&lt;p&gt;The invalid value itself is not returned as such: &lt;code&gt;ValidationFailedException::getValue()&lt;/code&gt; isn't serialized. Only the &lt;code&gt;{{ value }}&lt;/code&gt; parameter of some messages echoes it. Keep that in mind for sensitive fields: a password rejected by a &lt;code&gt;Regex&lt;/code&gt; constraint must not come back in plain text in the response, let alone in your logs.&lt;/p&gt;

&lt;p&gt;In debug mode, the response also includes the exception class and the full stack trace. That's invaluable in development, and an information leak if &lt;code&gt;APP_DEBUG&lt;/code&gt; stays on in production. This isn't hypothetical: I run into it regularly during audits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Logging: a 422 is not an incident
&lt;/h2&gt;

&lt;p&gt;Symfony logs 4xx HTTP errors at the &lt;code&gt;warning&lt;/code&gt; level. That is in fact new in 8.2 for exceptions that get their status code through &lt;code&gt;#[WithHttpStatus]&lt;/code&gt; or &lt;code&gt;framework.exceptions&lt;/code&gt;, such as &lt;code&gt;ValidationFailedException&lt;/code&gt;: before, they were logged at &lt;code&gt;error&lt;/code&gt;, which triggered the &lt;code&gt;fingers_crossed&lt;/code&gt; handler from the Monolog recipe. For a public API, validation errors are part of normal operation: thousands of incorrect inputs a day mean as many &lt;code&gt;warning&lt;/code&gt; lines, and the real warnings get lost in the noise. I take them down one level:&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;exceptions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;Symfony\Component\Validator\Exception\ValidationFailedException&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;log\_level&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;info&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the other hand, I keep an alert on the &lt;strong&gt;rate&lt;/strong&gt; of 422s per endpoint. A sudden spike after a deployment is often a mobile client that hasn't been updated, or a constraint that has become too strict. A single validation error is normal; a wave of them is an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the contract, not the messages
&lt;/h2&gt;

&lt;p&gt;The exercise's tests check what a client really expects: the 422 status code, the problem &lt;code&gt;type&lt;/code&gt; and the list of fields in error.&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;assertResponseStatusCodeSame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$problem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;json\_decode&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getResponse&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getContent&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://symfony.com/errors/validation'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$problem&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSame&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'username'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nf"&gt;array\_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$problem&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'violations'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'propertyPath'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No test on the message text. If you want to go further, also check each violation's &lt;code&gt;type&lt;/code&gt;: that's what belongs to the contract you've promised your clients.&lt;/p&gt;

&lt;h2&gt;
  
  
  My takeaways
&lt;/h2&gt;

&lt;p&gt;The new feature in Symfony 8.2 is small: an exception that knows which HTTP status code it stands for. But it brings consistency where there was none: manual validation, &lt;code&gt;#[MapRequestPayload]&lt;/code&gt; and the conversion of a database error now all go through the same path and produce the same response.&lt;/p&gt;

&lt;p&gt;The rest is up to you: pick the right status code, stick to a standard format, give clients stable codes rather than sentences, and don't mistake invalid input for an incident.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://apprendre.forelse.fr/en/practice/symfony-8-2-validation-422?utm_source=rss&amp;amp;utm_medium=feed&amp;amp;utm_campaign=api-validation-errors-400-or-422" rel="noopener noreferrer"&gt;exercise&lt;/a&gt; takes a few minutes in the browser, on a development build of Symfony 8.2. The two requests in the "Requests" tab show the response before and after.&lt;/p&gt;

</description>
      <category>php</category>
      <category>symfony</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Two-factor authentication with Symfony 8.2: one authenticator, one voter, and that's it</title>
      <dc:creator>Arnaud POINTET</dc:creator>
      <pubDate>Sun, 25 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/oipnet/two-factor-authentication-with-symfony-82-one-authenticator-one-voter-and-thats-it-1hpf</link>
      <guid>https://dev.to/oipnet/two-factor-authentication-with-symfony-82-one-authenticator-one-voter-and-thats-it-1hpf</guid>
      <description>&lt;p&gt;Until now, two-factor authentication in Symfony meant either a bundle, &lt;a href="https://github.com/scheb/2fa" rel="noopener noreferrer"&gt;scheb/2fa-bundle&lt;/a&gt;, or home-made code that worked around the firewall. The most common workaround looks like this: after the password, the user is logged in; a second authenticator checks the code and adds a role such as &lt;code&gt;ROLE_2FA_SUCCEED&lt;/code&gt; to the token; a subscriber redirects users without that role to the code form.&lt;/p&gt;

&lt;p&gt;It works, but it is fragile. The subscriber only protects the routes it is given, and any page you forget opens with the password alone. The role added to the token does not exist on the user: with an entity that does not implement &lt;code&gt;EquatableInterface&lt;/code&gt;, as is the case with the one &lt;code&gt;make:user&lt;/code&gt; generates, the &lt;code&gt;ContextListener&lt;/code&gt; compares the roles of the refreshed user with those of the token, finds them different, and clears the token on the next request.&lt;/p&gt;

&lt;p&gt;Symfony 8.2, expected at the end of November, makes this much simpler. The token now remembers &lt;strong&gt;how&lt;/strong&gt; the user proved their identity, and a voter can ask the firewall for a new proof instead of answering 403. With these two building blocks, SMS two-factor authentication fits in an authenticator, a voter, an entry point, the service that sends the code and a few lines of &lt;code&gt;security.yaml&lt;/code&gt;. The code in this article runs on the development version of Symfony 8.2, with functional tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea: two proofs, not a role
&lt;/h2&gt;

&lt;p&gt;I introduced these features in &lt;a href="https://apprendre.forelse.fr/en/blog/asking-for-proof-again-before-sensitive-actions?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=two-factor-authentication-with-symfony-8-2" rel="noopener noreferrer"&gt;Being logged in is not enough: asking for proof again before sensitive actions&lt;/a&gt;. In short: an authenticator states the method it checks with an &lt;code&gt;AuthenticationMethodBadge&lt;/code&gt; (&lt;code&gt;pwd&lt;/code&gt;, &lt;code&gt;sms&lt;/code&gt;, &lt;code&gt;otp&lt;/code&gt;…, the names from RFC 8176), and Symfony stores the method and the time on the token:&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;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getAuthenticationProofs&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// ['pwd' =&amp;gt; 1793050000, 'sms' =&amp;gt; 1793050042]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here we use them for something else: not to protect one action, but the whole application, right from login.&lt;/p&gt;

&lt;p&gt;The key point for two-factor authentication: when the same user authenticates a second time in the session, Symfony &lt;strong&gt;adds&lt;/strong&gt; the new proof to those of the previous token instead of replacing them. That is exactly the two-factor flow: the password first, the code next, and a token that carries both.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;form_login&lt;/code&gt; and &lt;code&gt;json_login&lt;/code&gt; already state &lt;code&gt;pwd&lt;/code&gt;. All that is left is to write the authenticator for the code, and to tell the firewall that protected pages require both proofs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The configuration
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/packages/security.yaml&lt;/span&gt;
&lt;span class="na"&gt;security&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;firewalls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;lazy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
            &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app_user_provider&lt;/span&gt;
            &lt;span class="na"&gt;form_login&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;login_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app_login&lt;/span&gt;
                &lt;span class="na"&gt;check_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app_login&lt;/span&gt;
                &lt;span class="na"&gt;enable_csrf&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
            &lt;span class="c1"&gt;# The second factor: one more authenticator on the same firewall.&lt;/span&gt;
            &lt;span class="na"&gt;custom_authenticators&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;App\Security\SmsCodeAuthenticator&lt;/span&gt;
            &lt;span class="c1"&gt;# When the voter asks for a proof, this entry point decides which one.&lt;/span&gt;
            &lt;span class="na"&gt;re_authentication_entry_point&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;App\Security\AskForMissingProof&lt;/span&gt;
            &lt;span class="c1"&gt;# Limits password and code attempts (symfony/rate-limiter component).&lt;/span&gt;
            &lt;span class="na"&gt;login_throttling&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;max_attempts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
                &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;15&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;minutes'&lt;/span&gt;
            &lt;span class="na"&gt;logout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app_logout&lt;/span&gt;

    &lt;span class="na"&gt;access_control&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;^/login&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;roles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;PUBLIC_ACCESS&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;^/logout&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;roles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;PUBLIC_ACCESS&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;^/verify&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;roles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;IS_AUTHENTICATED_FULLY&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;^/&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;roles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;TWO_FACTORS&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last line does all the work: the whole application requires &lt;code&gt;TWO_FACTORS&lt;/code&gt;, except login, logout and the code form. Protection is closed by default. A page added tomorrow is protected without anyone thinking about it.&lt;/p&gt;

&lt;p&gt;The code form requires &lt;code&gt;IS_AUTHENTICATED_FULLY&lt;/code&gt;, not &lt;code&gt;PUBLIC_ACCESS&lt;/code&gt;: you must have given your password in this session to receive a code, a "remember me" cookie is not enough.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TWO_FACTORS&lt;/code&gt; is not a role: it is an attribute decided by a voter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The voter: both proofs, or a new request
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/Security/Voter/TwoFactorsVoter.php&lt;/span&gt;
&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Security\Voter&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Entity\User&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\Security\Core\Authentication\AuthenticationMethod&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\Security\Core\Authentication\Token\TokenInterface&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\Security\Core\Authorization\Voter\Vote&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\Security\Core\Authorization\Voter\Voter&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;TwoFactorsVoter&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Voter&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;const&lt;/span&gt; &lt;span class="no"&gt;ATTRIBUTE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'TWO_FACTORS'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;supports&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$attribute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;mixed&lt;/span&gt; &lt;span class="nv"&gt;$subject&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&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;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ATTRIBUTE&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$attribute&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;voteOnAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$attribute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;mixed&lt;/span&gt; &lt;span class="nv"&gt;$subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;TokenInterface&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;?Vote&lt;/span&gt; &lt;span class="nv"&gt;$vote&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="kt"&gt;bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$proofs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getAuthenticationProofs&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$proofs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;AuthenticationMethod&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;PASSWORD&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$proofs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;AuthenticationMethod&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SMS&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="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="c1"&gt;// A new proof only helps users who can provide one: without a phone, it's a 403.&lt;/span&gt;
        &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getPhoneNumber&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$vote&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;requestReAuthentication&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$attribute&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="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$vote-&amp;gt;requestReAuthentication()&lt;/code&gt; is the other new feature in 8.2. The voter denies access, but tells the firewall that a new authentication could change its answer. Instead of a 403, the firewall remembers the requested page and calls its re-authentication entry point.&lt;/p&gt;

&lt;p&gt;The voter only does this for users who have a phone. For the others, a new form would lead nowhere: a 403 is more honest than a loop. In a real application, this is where you would send them to second-factor enrollment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The entry point: asking for what is missing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/Security/AskForMissingProof.php&lt;/span&gt;
&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Security&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\RedirectResponse&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\HttpFoundation\Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\HttpFoundation\Response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Routing\Generator\UrlGeneratorInterface&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\Security\Core\Authentication\AuthenticationMethod&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\Security\Core\Authentication\Token\TokenInterface&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\Security\Http\EntryPoint\ReAuthenticationEntryPointInterface&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;AskForMissingProof&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ReAuthenticationEntryPointInterface&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;UrlGeneratorInterface&lt;/span&gt; &lt;span class="nv"&gt;$urls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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;startReAuthentication&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;TokenInterface&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// No password (session reopened by "remember me"): start with the password.&lt;/span&gt;
        &lt;span class="nv"&gt;$route&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getAuthenticationProofs&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="nc"&gt;AuthenticationMethod&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;PASSWORD&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'app_verify'&lt;/span&gt;
            &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'app_login'&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;RedirectResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$route&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;Without &lt;code&gt;re_authentication_entry_point&lt;/code&gt;, the firewall would use the entry point of &lt;code&gt;form_login&lt;/code&gt;, which can re-authenticate too: it would send the user to the login page, and they would type their password again without ever seeing the code form. Ours checks which proof is missing.&lt;/p&gt;

&lt;p&gt;The "remember me" cookie is handled along the way: a session reopened by that cookie leaves no proof, Symfony explicitly excludes it. The voter therefore denies it, and the entry point sends the user to the password first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code authenticator
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/Security/SmsCodeAuthenticator.php&lt;/span&gt;
&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Security&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\Bundle\SecurityBundle\Security&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\RedirectResponse&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\HttpFoundation\Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\HttpFoundation\Response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Routing\Generator\UrlGeneratorInterface&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\Security\Core\Authentication\AuthenticationMethod&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\Security\Core\Authentication\Token\TokenInterface&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\Security\Core\Exception\AuthenticationException&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\Security\Core\Exception\CustomUserMessageAuthenticationException&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\Security\Http\Authenticator\AbstractAuthenticator&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\Security\Http\Authenticator\InteractiveAuthenticatorInterface&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\Security\Http\Authenticator\Passport\Badge\AuthenticationMethodBadge&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\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge&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\Security\Http\Authenticator\Passport\Badge\UserBadge&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\Security\Http\Authenticator\Passport\Credentials\CustomCredentials&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\Security\Http\Authenticator\Passport\Passport&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\Security\Http\SecurityRequestAttributes&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\Security\Http\Util\TargetPathTrait&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;SmsCodeAuthenticator&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;AbstractAuthenticator&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;InteractiveAuthenticatorInterface&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;TargetPathTrait&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;Security&lt;/span&gt; &lt;span class="nv"&gt;$security&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;SmsCodes&lt;/span&gt; &lt;span class="nv"&gt;$codes&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;UrlGeneratorInterface&lt;/span&gt; &lt;span class="nv"&gt;$urls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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;supports&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?bool&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;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isMethod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'POST'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="s1"&gt;'app_verify'&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;attributes&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="s1"&gt;'_route'&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;authenticate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Passport&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// The code completes the password: on its own, it logs nobody in.&lt;/span&gt;
        &lt;span class="nv"&gt;$user&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;security&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CustomUserMessageAuthenticationException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Log in with your password first.'&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;Passport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UserBadge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getUserIdentifier&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CustomCredentials&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;codes&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getPayload&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'code'&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;new&lt;/span&gt; &lt;span class="nc"&gt;CsrfTokenBadge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'verify'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getPayload&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'_csrf_token'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
                &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AuthenticationMethodBadge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AuthenticationMethod&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SMS&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="c1"&gt;// Only an interactive authentication leaves a proof on the token.&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;isInteractive&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;onAuthenticationSuccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;TokenInterface&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$firewallName&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getTargetPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getSession&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;$firewallName&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;removeTargetPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getSession&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;$firewallName&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;RedirectResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$target&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;urls&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app_account'&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;onAuthenticationFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;AuthenticationException&lt;/span&gt; &lt;span class="nv"&gt;$exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getSession&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SecurityRequestAttributes&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;AUTHENTICATION_ERROR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$exception&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;RedirectResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app_verify'&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;Three details matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;InteractiveAuthenticatorInterface&lt;/code&gt;.&lt;/strong&gt; Without it, the badge is ignored: Symfony only records proofs from an interactive authentication, one where the user has just done something. It is the most likely omission, and it raises no error: the code is accepted, but the &lt;code&gt;sms&lt;/code&gt; proof never shows up, and the user is sent back to the form in a loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The user comes from the session.&lt;/strong&gt; The authenticator does not read an identifier from the request: it reuses the one the password already authenticated. A code alone logs nobody in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The passport has a &lt;code&gt;UserBadge&lt;/code&gt;.&lt;/strong&gt; That is what lets &lt;code&gt;login_throttling&lt;/code&gt; count code attempts too: on the sixth attempt within fifteen minutes, even the right code is refused. Without a limit, a script eventually hits the right one among the million possible codes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The code itself
&lt;/h2&gt;

&lt;p&gt;The service that sends and checks the code has nothing specific to Symfony 8.2. It goes through the Notifier component (&lt;code&gt;symfony/notifier&lt;/code&gt;), with your provider's SMS transport. The code is random, only its hash is kept in the session along with an expiry, and it can be used only once.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/Security/SmsCodes.php&lt;/span&gt;
&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Entity\User&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\RequestStack&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\Notifier\Message\SmsMessage&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\Notifier\TexterInterface&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;SmsCodes&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;const&lt;/span&gt; &lt;span class="no"&gt;KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'_sms_code'&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;const&lt;/span&gt; &lt;span class="no"&gt;LIFETIME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&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;TexterInterface&lt;/span&gt; &lt;span class="nv"&gt;$texter&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;RequestStack&lt;/span&gt; &lt;span class="nv"&gt;$requests&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;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&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;$code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'%06d'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;random_int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;999999&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="c1"&gt;// In the session, only the hash of the code and its expiry.&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;requests&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getSession&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'hash'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'expires'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;LIFETIME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;texter&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SmsMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getPhoneNumber&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s2"&gt;"Your login code: &lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&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;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$session&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;requests&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$session&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="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$expected&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$expected&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'expires'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;time&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="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;hash_equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$expected&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'hash'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$code&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="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$session&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// single use&lt;/span&gt;

        &lt;span class="k"&gt;return&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$expected&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;requests&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getSession&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$expected&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$expected&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'expires'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nb"&gt;time&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 controller displays the form and only sends an SMS if no valid code is already pending. One SMS per page refresh means a growing bill, and an open door for anyone who wants to flood a phone number. For the same reason, a "Resend code" button should go through a rate limiter, for example one resend per minute.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/Controller/SecurityController.php (excerpt)&lt;/span&gt;
&lt;span class="na"&gt;#[Route('/verify', name: 'app_verify', methods: ['GET', 'POST'])]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;CurrentUser&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;SmsCodes&lt;/span&gt; &lt;span class="nv"&gt;$codes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;AuthenticationUtils&lt;/span&gt; &lt;span class="nv"&gt;$authenticationUtils&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// No phone, nothing to send: in a real application, redirect to enrollment.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getPhoneNumber&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;createAccessDeniedException&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// The POST is handled by SmsCodeAuthenticator.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$codes&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$codes&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'security/verify.html.twig'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'error'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$authenticationUtils&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getLastAuthenticationError&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;h2&gt;
  
  
  The flow, end to end
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Ada requests &lt;code&gt;/account&lt;/code&gt;. She is not logged in: &lt;code&gt;form_login&lt;/code&gt; sends her to &lt;code&gt;/login&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;She gives her password. The token carries &lt;code&gt;pwd&lt;/code&gt;, and the firewall takes her back to &lt;code&gt;/account&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The voter denies &lt;code&gt;TWO_FACTORS&lt;/code&gt; and asks for a new proof. The firewall remembers &lt;code&gt;/account&lt;/code&gt;, and the entry point sends her to &lt;code&gt;/verify&lt;/code&gt;, which sends her a code.&lt;/li&gt;
&lt;li&gt;She types the code. The token carries &lt;code&gt;pwd&lt;/code&gt; and &lt;code&gt;sms&lt;/code&gt;, and she is back on &lt;code&gt;/account&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The voter does not look at the time of the proofs: once the code is given, it is valid for the whole session, as in most two-factor setups, and logging out clears everything. Since the time is stored, nothing stops you from requiring an &lt;code&gt;sms&lt;/code&gt; proof less than 24 hours old.&lt;/p&gt;

&lt;p&gt;No more subscriber, no more role added to the token, no more list of routes to keep up to date. The user keeps their usual roles, and an entity without &lt;code&gt;EquatableInterface&lt;/code&gt; is not a problem: the tests of the example use a &lt;code&gt;User&lt;/code&gt; class that does not implement it, and the session survives from one request to the next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you adopt it
&lt;/h2&gt;

&lt;p&gt;This article shows the mechanism, not a complete two-factor system. Three things to know.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SMS is one of the weakest second factors.&lt;/strong&gt; It is exposed to SIM swapping, and NIST classifies it as a "restricted" authenticator. The same pattern works with an authenticator app: the authenticator checks a TOTP code with a library such as &lt;code&gt;spomky-labs/otphp&lt;/code&gt;, states &lt;code&gt;AuthenticationMethod::ONE_TIME_PASSWORD&lt;/code&gt;, and the voter requires &lt;code&gt;otp&lt;/code&gt; instead of &lt;code&gt;sms&lt;/code&gt;. You then need one secret per account, saved at enrollment after a first code is confirmed, and never shown again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enrollment and recovery are up to you.&lt;/strong&gt; Registering the phone (or the TOTP secret), offering hashed, single-use backup codes, and protecting the deactivation of two-factor authentication with a recent proof. For that last point, &lt;code&gt;IS_AUTHENTICATED_RECENTLY&lt;/code&gt; and the same re-authentication mechanism do the job: I covered it in &lt;a href="https://apprendre.forelse.fr/en/blog/asking-for-proof-again-before-sensitive-actions?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=two-factor-authentication-with-symfony-8-2" rel="noopener noreferrer"&gt;Being logged in is not enough: asking for proof again before sensitive actions&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A limit of the mechanism.&lt;/strong&gt; Re-authentication only kicks in when a single attribute is denied. An &lt;code&gt;access_control&lt;/code&gt; rule that lists several attributes in &lt;code&gt;roles&lt;/code&gt; always gives a 403: keep &lt;code&gt;TWO_FACTORS&lt;/code&gt; alone on its line. Passing several attributes to the same decision is deprecated in 8.2 anyway.&lt;/p&gt;

&lt;p&gt;If you want all of this ready to use, or cannot wait for Symfony 8.2, scheb/2fa-bundle is still the right choice: it brings TOTP, backup codes, trusted devices and codes by email. But if what you need is a second factor on an application you control, Symfony 8.2 lets you write it in under two hundred lines, without working around the firewall.&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>php</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The CSRF token: what it protects, when you still need it, and how Symfony, Laravel, Django and Rails handle it</title>
      <dc:creator>Arnaud POINTET</dc:creator>
      <pubDate>Sun, 25 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/oipnet/the-csrf-token-what-it-protects-when-you-still-need-it-and-how-symfony-laravel-django-and-19in</link>
      <guid>https://dev.to/oipnet/the-csrf-token-what-it-protects-when-you-still-need-it-and-how-symfony-laravel-django-and-19in</guid>
      <description>&lt;p&gt;"The CSRF token is invalid. Please try to resubmit the form." in Symfony, "419 Page Expired" in Laravel, "CSRF verification failed. Request aborted." in Django: every web developer has run into one of these errors. The CSRF token is one of those protections everyone uses, because the framework turns it on by default, and few people can explain. In my audits I regularly find routes excluded from the check "because it was getting in the way", or, the other way round, &lt;code&gt;Bearer&lt;/code&gt; token APIs that demand a CSRF token for nothing.&lt;/p&gt;

&lt;p&gt;This article covers the whole question: what the token really protects, when it is still needed in the age of &lt;code&gt;SameSite&lt;/code&gt; cookies, and how Symfony, Laravel, Django and Rails take care of it. Along the way, it explains a detail from a recent &lt;a href="https://apprendre.forelse.fr/en/practice/laravel-13-35-route-query?utm_source=rss&amp;amp;utm_medium=feed&amp;amp;utm_campaign=csrf-tokens-across-frameworks" rel="noopener noreferrer"&gt;Practice exercise&lt;/a&gt;: why a simple search using the new HTTP &lt;code&gt;QUERY&lt;/code&gt; method can be asked for a token.&lt;/p&gt;

&lt;h2&gt;
  
  
  The attack: making the victim's browser act
&lt;/h2&gt;

&lt;p&gt;CSRF stands for &lt;em&gt;Cross-Site Request Forgery&lt;/em&gt;. The attacker steals nothing. They get the victim's browser to send a request to a site where the victim is logged in.&lt;/p&gt;

&lt;p&gt;Imagine you are logged in to your bank, and you then visit a booby-trapped page that contains this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"https://bank.example/transfer"&lt;/span&gt; &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"trap"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"iban"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"GB33 ... attacker's account"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"amount"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"2000"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;trap&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The form submits itself to the bank. The browser attaches the bank's session cookie, as it does for any request to that domain. On the server side, nothing distinguishes this request from a genuine transfer: right session, right fields, right method.&lt;/p&gt;

&lt;p&gt;That is the key point: &lt;strong&gt;the server recognises the user by a cookie that the browser sends on its own&lt;/strong&gt;. As long as that is the case, any site can trigger actions on the user's behalf. The attacker cannot see the response, the same-origin policy prevents it, but they do not need to: the transfer is done.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the token changes
&lt;/h2&gt;

&lt;p&gt;The CSRF token is a random value that the server places in its own pages, usually in a hidden field, and requires on every submission. The attacker's site can build a form that targets the bank, but it cannot read a page from the bank to grab the token: the same-origin policy forbids it. Its request arrives without a valid token, and the server rejects it.&lt;/p&gt;

&lt;p&gt;So the token does not protect you against an attacker who can run JavaScript on your own domain. An XSS flaw reads the token from the page just like your own code would. CSRF and XSS are two separate problems, and you cannot fix the first without fixing the second.&lt;/p&gt;

&lt;h2&gt;
  
  
  When do you need a token?
&lt;/h2&gt;

&lt;p&gt;The rule fits in one sentence: &lt;strong&gt;any request that changes something, on an application that authenticates with a cookie&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Requests that change something&lt;/strong&gt; : &lt;code&gt;POST&lt;/code&gt;, &lt;code&gt;PUT&lt;/code&gt;, &lt;code&gt;PATCH&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt;. Reads (&lt;code&gt;GET&lt;/code&gt;, &lt;code&gt;HEAD&lt;/code&gt;, &lt;code&gt;OPTIONS&lt;/code&gt;) do not need one, provided they really change nothing. A deletion triggered by a &lt;code&gt;GET&lt;/code&gt; link is protected by no framework at all: a simple &lt;code&gt;&amp;lt;img src="https://site.example/dish/12/delete"&amp;gt;&lt;/code&gt; tag on another page is enough to trigger it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cookie authentication&lt;/strong&gt; : a classic session, a "remember me" cookie, or an API token stored in a cookie. It is the automatic sending of the cookie that makes the attack possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The login form too.&lt;/strong&gt; An attacker can log the victim in to the &lt;em&gt;attacker's&lt;/em&gt; account, then collect whatever the victim enters there (an address, a saved card). This is &lt;em&gt;login CSRF&lt;/em&gt;. Symfony covers it with the &lt;code&gt;enable_csrf&lt;/code&gt; option of &lt;code&gt;form_login&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Conversely, an API that authenticates with the &lt;code&gt;Authorization: Bearer ...&lt;/code&gt; header does not need a CSRF token: another site cannot get the victim's browser to add that header. That is exactly why Laravel's &lt;code&gt;api&lt;/code&gt; group does not check the token.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about SameSite cookies?
&lt;/h3&gt;

&lt;p&gt;A cookie's &lt;code&gt;SameSite&lt;/code&gt; attribute tells the browser not to send it on requests coming from another site. With &lt;code&gt;SameSite=Lax&lt;/code&gt;, the session cookie is no longer sent with the trap form's &lt;code&gt;POST&lt;/code&gt;: the attack above fails, even without a token. Symfony and Laravel set &lt;code&gt;SameSite=Lax&lt;/code&gt; on their session cookie by default. Chrome also applies a "Lax by default" rule to cookies that specify nothing, in a looser form: for two minutes after it is set, the cookie still goes along with a top-level &lt;code&gt;POST&lt;/code&gt;. Firefox and Safari do not make that choice by default.&lt;/p&gt;

&lt;p&gt;It is an excellent second line of defence, but I never leave it on its own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Lax&lt;/code&gt; lets top-level &lt;code&gt;GET&lt;/code&gt; navigations through.&lt;/strong&gt; A link clicked on another site carries the cookie. An action triggered by &lt;code&gt;GET&lt;/code&gt; therefore remains exposed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Same site" is not "same origin".&lt;/strong&gt; &lt;code&gt;blog.example.com&lt;/code&gt; and &lt;code&gt;app.example.com&lt;/code&gt; are the same site as far as &lt;code&gt;SameSite&lt;/code&gt; is concerned. A compromised subdomain, or one hosted by a third party, bypasses the protection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not every browser applies it to cookies that specify nothing&lt;/strong&gt; , and a configuration change can disable it without anyone noticing, for example &lt;code&gt;SameSite=None&lt;/code&gt; set to make an iframe integration work.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The case of the QUERY method
&lt;/h3&gt;

&lt;p&gt;The HTTP &lt;code&gt;QUERY&lt;/code&gt; method, standardised in 2026, is a read like &lt;code&gt;GET&lt;/code&gt;, but with its criteria in the request body. Laravel 13.35 can route it, and the Practice exercise that introduces this feature includes a warning about the CSRF token. Laravel's CSRF middleware lets read methods (&lt;code&gt;GET&lt;/code&gt;, &lt;code&gt;HEAD&lt;/code&gt;, &lt;code&gt;OPTIONS&lt;/code&gt;) through and checks all the others. &lt;code&gt;QUERY&lt;/code&gt; is a read according to its RFC, but Laravel does not count it as one yet: declared in &lt;code&gt;routes/web.php&lt;/code&gt;, a &lt;code&gt;QUERY&lt;/code&gt; route requires a token, just like a &lt;code&gt;POST&lt;/code&gt;. Since Laravel 13 there is a nuance: the middleware also accepts without a token a request that the browser marks as coming from the same origin (the &lt;code&gt;Sec-Fetch-Site: same-origin&lt;/code&gt; header). A &lt;code&gt;QUERY&lt;/code&gt; sent by your own pages over HTTPS therefore goes through; the 419 mostly shows up in local development over &lt;code&gt;http&lt;/code&gt;, where the browser does not send that header.&lt;/p&gt;

&lt;p&gt;Is forgetting a token a real risk here? Not really. An HTML form can only send &lt;code&gt;GET&lt;/code&gt; and &lt;code&gt;POST&lt;/code&gt;, and a &lt;code&gt;QUERY&lt;/code&gt; request sent with &lt;code&gt;fetch()&lt;/code&gt; from another site triggers a CORS &lt;em&gt;preflight&lt;/em&gt; request, which your server rejects unless it allows that origin. The check adds nothing here, and it gets in the way. Hence the exercise's advice: a &lt;code&gt;QUERY&lt;/code&gt; search belongs in &lt;code&gt;routes/api.php&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How frameworks take care of it
&lt;/h2&gt;

&lt;p&gt;The principle is the same everywhere. The differences come down to three points: where the token is stored, how it is sent, and what happens when it is missing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Symfony
&lt;/h3&gt;

&lt;p&gt;The Form component adds and checks the token on its own, in a hidden &lt;code&gt;_token&lt;/code&gt; field. For a hand-written form, you generate the token with &lt;code&gt;csrf_token('token_id')&lt;/code&gt; in Twig and check it with &lt;code&gt;isCsrfTokenValid()&lt;/code&gt; in the controller, or with the &lt;code&gt;#[IsCsrfTokenValid]&lt;/code&gt; attribute:&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('/dish/{id}/delete', methods: ['POST'])]&lt;/span&gt;
&lt;span class="na"&gt;#[IsCsrfTokenValid(new Expression('"delete-dish-" ~ args["dish"].getId()'), tokenKey: '\_token')]&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;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Dish&lt;/span&gt; &lt;span class="nv"&gt;$dish&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Historically, the token is stored in the session. Since Symfony 7.2, the &lt;code&gt;symfony/form&lt;/code&gt; recipe enables &lt;strong&gt;stateless tokens&lt;/strong&gt; for forms: instead of comparing the token with the session, Symfony checks that the &lt;code&gt;Origin&lt;/code&gt; or &lt;code&gt;Referer&lt;/code&gt; header matches the site's address, with a fallback cookie set by a small Stimulus controller. No session needs to be started just to display a form, which makes public pages easier to cache.&lt;/p&gt;

&lt;p&gt;When the check fails, the form is not processed and carries the error "The CSRF token is invalid. Please try to resubmit the form."&lt;/p&gt;

&lt;h3&gt;
  
  
  Laravel
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;PreventRequestForgery&lt;/code&gt; middleware (&lt;code&gt;ValidateCsrfToken&lt;/code&gt; before Laravel 13) applies to the whole &lt;code&gt;web&lt;/code&gt; group. Since Laravel 13, it first lets through requests marked &lt;code&gt;Sec-Fetch-Site: same-origin&lt;/code&gt; (see the &lt;code&gt;QUERY&lt;/code&gt; case above), then checks the token. In a Blade form, the &lt;code&gt;@csrf&lt;/code&gt; directive adds the hidden &lt;code&gt;_token&lt;/code&gt; field. For JavaScript calls, Laravel also accepts the token in the &lt;code&gt;X-CSRF-TOKEN&lt;/code&gt; header, or in &lt;code&gt;X-XSRF-TOKEN&lt;/code&gt;: Laravel sets an &lt;code&gt;XSRF-TOKEN&lt;/code&gt; cookie that libraries such as Axios send back in that header automatically.&lt;/p&gt;

&lt;p&gt;The token is stored in the session. When it is missing or does not match, Laravel responds with &lt;strong&gt;419 Page Expired&lt;/strong&gt; , a status code that does not exist in HTTP and that Laravel chose for this error. Exclusions are declared in &lt;code&gt;bootstrap/app.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Middleware&lt;/span&gt; &lt;span class="nv"&gt;$middleware&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$middleware&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;preventRequestForgery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;except&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'webhooks/stripe'&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;Excluding a webhook is legitimate: it is not authenticated by a cookie, and it must have its own check, a signature. Excluding a route because it "was returning 419", on the other hand, almost always hides a forgotten &lt;code&gt;@csrf&lt;/code&gt; or a lost session.&lt;/p&gt;

&lt;h3&gt;
  
  
  Django
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;CsrfViewMiddleware&lt;/code&gt; middleware is enabled by default. In a template, &lt;code&gt;{% csrf_token %}&lt;/code&gt; adds the hidden field. The token is stored in a &lt;code&gt;csrftoken&lt;/code&gt; cookie (or in the session if you ask for it), and JavaScript sends it back in the &lt;code&gt;X-CSRFToken&lt;/code&gt; header. Django also checks the &lt;code&gt;Origin&lt;/code&gt; header, and over HTTPS the &lt;code&gt;Referer&lt;/code&gt;: behind a proxy or with several domains, you have to declare the accepted origins in &lt;code&gt;CSRF_TRUSTED_ORIGINS&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When the check fails, Django responds with a &lt;strong&gt;403&lt;/strong&gt; and the "CSRF verification failed" page.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rails
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ApplicationController&lt;/code&gt; inherits the protection: &lt;code&gt;protect_from_forgery&lt;/code&gt; is on by default, and raises an exception when the check fails. The &lt;code&gt;csrf_meta_tags&lt;/code&gt; helper puts the token in the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, and the form helpers add it on their own. On the JavaScript side, Rails UJS or Turbo send it back in the &lt;code&gt;X-CSRF-Token&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;When the check fails, Rails raises &lt;code&gt;ActionController::InvalidAuthenticityToken&lt;/code&gt;, which results in a &lt;strong&gt;422&lt;/strong&gt; response.&lt;/p&gt;

&lt;h3&gt;
  
  
  Summary
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Framework&lt;/th&gt;
&lt;th&gt;In the form&lt;/th&gt;
&lt;th&gt;JavaScript header&lt;/th&gt;
&lt;th&gt;On failure&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Symfony&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;_token&lt;/code&gt; field (Form component, &lt;code&gt;csrf_token()&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;none by default (stateless tokens: handled by the Stimulus controller)&lt;/td&gt;
&lt;td&gt;error on the form, or an exception when checked by hand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Laravel&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@csrf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;X-CSRF-TOKEN&lt;/code&gt; or &lt;code&gt;X-XSRF-TOKEN&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;419 Page Expired&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Django&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{% csrf_token %}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;X-CSRFToken&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rails&lt;/td&gt;
&lt;td&gt;added by the form helpers&lt;/td&gt;
&lt;td&gt;&lt;code&gt;X-CSRF-Token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;422&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When the error hits a real user
&lt;/h2&gt;

&lt;p&gt;Whatever the framework, the same causes come up when a legitimate user gets rejected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The session has expired.&lt;/strong&gt; The user left the tab open over lunch. It is the most common cause, and the only normal one: show a clear message and offer to resubmit the form.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The session cookie does not arrive.&lt;/strong&gt; A misconfigured cookie domain, a &lt;code&gt;Secure&lt;/code&gt; cookie on a site served over &lt;code&gt;http&lt;/code&gt;, a cookie blocked by the browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The page is served from a cache.&lt;/strong&gt; An HTTP cache hands out the same page, and therefore the same token, to everyone. Symfony's stateless tokens exist partly for this case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Several servers do not share their sessions.&lt;/strong&gt; The page comes from server A, the submission lands on server B, which does not know the token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The proxy is not declared.&lt;/strong&gt; The application thinks it is running over &lt;code&gt;http&lt;/code&gt; while the browser announces &lt;code&gt;https&lt;/code&gt;: every check that compares the origin fails (stateless Symfony, Django).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these causes is fixed by disabling the check.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I take away from it
&lt;/h2&gt;

&lt;p&gt;The CSRF token answers a precise problem: a browser that attaches an authentication cookie on its own to a request triggered by another site. Wherever that is not the case, such as a &lt;code&gt;Bearer&lt;/code&gt; API or a method another site cannot send without CORS, it adds almost nothing. Wherever it is the case, it is still necessary, even with &lt;code&gt;SameSite&lt;/code&gt;, which is only a second line of defence.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://apprendre.forelse.fr/en/practice/laravel-13-35-route-query?utm_source=rss&amp;amp;utm_medium=feed&amp;amp;utm_campaign=csrf-tokens-across-frameworks" rel="noopener noreferrer"&gt;exercise on the QUERY method&lt;/a&gt; takes a few minutes in the browser.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>symfony</category>
      <category>laravel</category>
    </item>
    <item>
      <title>Being logged in is not enough: asking for proof again before sensitive actions</title>
      <dc:creator>Arnaud POINTET</dc:creator>
      <pubDate>Sat, 24 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/oipnet/being-logged-in-is-not-enough-asking-for-proof-again-before-sensitive-actions-28o9</link>
      <guid>https://dev.to/oipnet/being-logged-in-is-not-enough-asking-for-proof-again-before-sensitive-actions-28o9</guid>
      <description>&lt;p&gt;In most applications, authentication is a boolean. You are logged in or you are not, and once you are, everything is open: viewing your invoices, changing your email address, deleting your account, generating an API key.&lt;/p&gt;

&lt;p&gt;Yet "logged in" covers very different situations. A session opened a minute ago with a password and a code. A session restored this morning by a "remember me" cookie set three weeks ago. A computer left unlocked over lunch. A session cookie stolen by a browser extension. To read a dashboard, the difference hardly matters. To change the address used to reset the password, it matters a lot.&lt;/p&gt;

&lt;p&gt;GitHub asks for your password again before you delete your account or create an access token. Your bank asks for a code before a transfer to a new payee. This is called step-up authentication: you don't ask for everything all the time, but for an extra proof at the moment it counts.&lt;/p&gt;

&lt;p&gt;Symfony 8.2 finally brings the building blocks to do it properly. But before the code come design choices, and they are what makes the difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three questions instead of one
&lt;/h2&gt;

&lt;p&gt;An authenticated session answers one question: &lt;strong&gt;who&lt;/strong&gt; the user is. To decide whether they may perform a sensitive action, you need two more.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;When&lt;/strong&gt; did they last prove their identity? Not when the session started: when they last did something only they could do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How&lt;/strong&gt; did they prove it? A password, a code received by SMS, a security key, a magic link in their inbox.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These questions are not new. OpenID Connect has long answered them in the ID token: &lt;code&gt;auth_time&lt;/code&gt; says when the user authenticated, &lt;code&gt;amr&lt;/code&gt; (authentication methods references) lists the methods used, and the client can require a recent authentication with the &lt;code&gt;max_age&lt;/code&gt; parameter. RFC 8176 standardizes the &lt;code&gt;amr&lt;/code&gt; values: &lt;code&gt;pwd&lt;/code&gt; for a password, &lt;code&gt;sms&lt;/code&gt;, &lt;code&gt;otp&lt;/code&gt;, &lt;code&gt;hwk&lt;/code&gt; for a hardware key, and so on. If your application delegates login to an identity provider, a good part of this article is settled in that provider's configuration, and your application only has to read this information.&lt;/p&gt;

&lt;p&gt;If your application does the authenticating, it is up to it to remember these answers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which actions to protect
&lt;/h2&gt;

&lt;p&gt;Protecting everything amounts to protecting nothing: a user asked for their password every ten minutes ends up pasting it from a text file, or leaving the service. I sort actions with three criteria.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the action allow taking over the account?&lt;/strong&gt; Changing the email address, the password, the phone number, disabling two-factor authentication, adding a security key. These are the top targets, because an attacker who succeeds once no longer needs the stolen session: they have the account. Changing the email is the most underestimated: it doesn't look like a security action, but it is the path password resets go through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the action irreversible, or does it cost money?&lt;/strong&gt; A transfer, an order, deleting an account or a project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the action export data in bulk?&lt;/strong&gt; A full export, creating an API key or an access token, adding a member with admin rights.&lt;/p&gt;

&lt;p&gt;Everything else (browsing, editing a public profile, changing preferences) can usually make do with the session.&lt;/p&gt;

&lt;p&gt;Two freshness levels are often enough. A proof less than a few hours old for sensitive pages to view, like invoices or login history. A proof a few minutes old for actions that hand over control of the account. The exact durations depend on your domain: a banking app does not have the same constraints as a project management tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Asking for the right proof
&lt;/h2&gt;

&lt;p&gt;Asking for proof again, yes, but which one? Two mistakes come up often.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Asking for a weaker proof than the one used to log in.&lt;/strong&gt; If the user logged in with a password and a security key, asking only for a code sent by email before disabling two-factor authentication opens a shortcut for anyone with access to their inbox. The proof you ask for must be at least as strong as the one the action lets you change or disable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Believing all second factors are equal.&lt;/strong&gt; SMS is still widespread, but it is exposed to SIM swapping: NIST classifies it as a "restricted" authenticator, to be used only knowingly and with an alternative offered to the user. GitHub, for instance, does not accept it for its sudo mode. An authenticator app (TOTP) or a security key (WebAuthn) is stronger. SMS is better than nothing, but that is no reason to make it the only safeguard for a large transfer.&lt;/p&gt;

&lt;p&gt;And sometimes there is nothing to ask for: a user who never registered a phone cannot provide a code. Sending them to a form they cannot fill in leads nowhere. For them, a clear refusal, with instructions to add a second factor, is more honest than a dead end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't break the flow
&lt;/h2&gt;

&lt;p&gt;A successful re-authentication must take the user back exactly where they were going. Otherwise they start over, get tired, and rightly blame you for wasting their time.&lt;/p&gt;

&lt;p&gt;A classic trap: protecting only the &lt;code&gt;POST&lt;/code&gt; request that saves the form. The user fills in the address change form, submits it, gets sent to the password page… and their input is lost. Symfony's firewall, like most mechanisms of this kind, only remembers the target page for a read request (&lt;code&gt;GET&lt;/code&gt;), excluding Ajax. It cannot replay a &lt;code&gt;POST&lt;/code&gt;. The fix is simple: also protect the &lt;strong&gt;display&lt;/strong&gt; of the form. The proof is requested before the user types anything, and it is still valid when they submit.&lt;/p&gt;

&lt;p&gt;For an API, redirecting to a form makes no sense. RFC 9470 (OAuth 2.0 Step-Up Authentication Challenge Protocol) describes the expected response: a 401 with a &lt;code&gt;WWW-Authenticate&lt;/code&gt; header carrying the &lt;code&gt;insufficient_user_authentication&lt;/code&gt; error, and optionally &lt;code&gt;max_age&lt;/code&gt; or &lt;code&gt;acr_values&lt;/code&gt; to say what is missing. The client then knows it must start a stronger authentication, rather than display "access denied".&lt;/p&gt;

&lt;h2&gt;
  
  
  The re-authentication screen is a login screen
&lt;/h2&gt;

&lt;p&gt;A page that only asks an already logged-in user for their password looks harmless. Yet it is exactly what someone who stole a session is looking for: a form to try passwords on, without having to guess the username. It therefore needs the same protections as a login form: attempt limiting, logging of failures, and one-time codes that expire quickly.&lt;/p&gt;

&lt;p&gt;After a successful sensitive action, notify the user through another channel. An email saying "your address was changed", sent to the &lt;strong&gt;old&lt;/strong&gt; address with a link to undo it, catches the case where every other protection was bypassed. It is the cheapest safety net in this article.&lt;/p&gt;

&lt;p&gt;Finally, a session restored by a "remember me" cookie must never count as a recent proof. The cookie proves that a browser was logged in at some point, not that the person in front of the screen knows the password.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before Symfony 8.2: a timestamp in the session
&lt;/h2&gt;

&lt;p&gt;Up to Symfony 8.1, nothing is built in for the "when" or the "how". &lt;code&gt;IS_AUTHENTICATED_FULLY&lt;/code&gt; only distinguishes a session opened by a form from one opened by the "remember me" cookie. So you have to record the time of the last proof yourself, for example in a &lt;code&gt;LoginSuccessEvent&lt;/code&gt; listener:&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;#[AsEventListener]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;\_\_invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LoginSuccessEvent&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// The "remember me" cookie triggers this event too: it proves nothing.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getAuthenticatedToken&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;RememberMeToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRequest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getSession&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'last\_proof'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a voter compares this timestamp with the current time. It works, but everything is on you: recording the method as well as the time, redirecting to the right page instead of a 403, not forgetting the cookie case. It is exactly the kind of code you write once, halfway, and never read again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Symfony 8.2 brings
&lt;/h2&gt;

&lt;p&gt;Symfony 8.2 (still in development as I write this) builds this mechanism into the Security component, in four pieces.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Freshness.&lt;/strong&gt; Two new attributes, &lt;code&gt;IS_AUTHENTICATED_RECENTLY&lt;/code&gt; (under two hours by default, the window of GitHub's sudo mode) and &lt;code&gt;IS_AUTHENTICATED_VERY_RECENTLY&lt;/code&gt; (under five minutes), work wherever &lt;code&gt;IS_AUTHENTICATED_FULLY&lt;/code&gt; does: &lt;code&gt;#[IsGranted]&lt;/code&gt;, &lt;code&gt;access_control&lt;/code&gt;, Twig. The durations are set in &lt;code&gt;security.yaml&lt;/code&gt;. A session opened by a "remember me" cookie fails both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Re-authentication instead of refusal.&lt;/strong&gt; When one of these attributes is denied, the firewall no longer responds with a 403: it remembers the requested page and calls a re-authentication entry point. &lt;code&gt;form_login&lt;/code&gt; provides one, which redirects to the login page with the username already filled in. For a page that only asks for the password, you write your own by implementing &lt;code&gt;ReAuthenticationEntryPointInterface&lt;/code&gt;, and declare it on the firewall with &lt;code&gt;re_authentication_entry_point&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The "how".&lt;/strong&gt; The token now records every method proven during the session, with the time it was last used, using the names from RFC 8176:&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;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getAuthenticationProofs&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// ['pwd' =&amp;gt; 1792300000, 'sms' =&amp;gt; 1792300060]&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An authenticator declares its method with an &lt;code&gt;AuthenticationMethodBadge&lt;/code&gt; on the passport. Only interactive authentications count, and the "remember me" cookie is explicitly excluded. An authenticator without a badge is recorded under the &lt;code&gt;*&lt;/code&gt; method, unspecified. On the OpenID Connect side, the OIDC authenticator reads &lt;code&gt;amr&lt;/code&gt; and &lt;code&gt;auth_time&lt;/code&gt; from the ID token to feed these proofs. A voter can then require, for example, an &lt;code&gt;sms&lt;/code&gt; proof less than five minutes old before a transfer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your own rules.&lt;/strong&gt; A voter can tell the firewall that a new proof would change its answer, with &lt;code&gt;$vote-&amp;gt;requestReAuthentication($attribute)&lt;/code&gt;. The entry point reads which attribute was denied and chooses what to ask for: a code for the transfer, the password for everything else. And the voter can also signal nothing, for the user who has no second factor: they keep their 403. One limit to know: the mechanism only kicks in when a single attribute is denied. A check combining several always yields a 403.&lt;/p&gt;

&lt;p&gt;The when, the how and what to ask for again each have their answer in the framework. The choices remain yours: which actions to protect, with which proof, and for how long.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;I turned these features into three exercises, to do in your browser, free and without an account, on a development build of Symfony 8.2:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://apprendre.forelse.fr/en/practice/symfony-8-2-mode-sudo?utm_source=rss&amp;amp;utm_medium=feed&amp;amp;utm_campaign=asking-for-proof-again-before-sensitive-actions" rel="noopener noreferrer"&gt;Asking for the password again before sensitive pages&lt;/a&gt;, sudo mode;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apprendre.forelse.fr/en/practice/symfony-8-2-preuve-code-sms?utm_source=rss&amp;amp;utm_medium=feed&amp;amp;utm_campaign=asking-for-proof-again-before-sensitive-actions" rel="noopener noreferrer"&gt;Require an SMS code before a transfer&lt;/a&gt;, authentication proofs;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apprendre.forelse.fr/en/practice/symfony-8-2-reauthentification-par-facteur?utm_source=rss&amp;amp;utm_medium=feed&amp;amp;utm_campaign=asking-for-proof-again-before-sensitive-actions" rel="noopener noreferrer"&gt;Ask for the right factor again instead of a 403&lt;/a&gt;, re-authentication from your voters.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>php</category>
      <category>symfony</category>
      <category>security</category>
      <category>authentication</category>
    </item>
    <item>
      <title>PHP2Shell: how I patched my Symfony server</title>
      <dc:creator>Arnaud POINTET</dc:creator>
      <pubDate>Thu, 22 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/oipnet/php2shell-how-i-patched-my-symfony-server-1j2a</link>
      <guid>https://dev.to/oipnet/php2shell-how-i-patched-my-symfony-server-1j2a</guid>
      <description>&lt;p&gt;On the evening of 20 October, two critical flaws were disclosed in PHP: CVE-2026-109618 and CVE-2026-109619, quickly nicknamed "PHP2Shell". They allow unauthenticated remote code execution on the server, and are rated 9.2 out of 10. By the next morning, real-world attacks were already being reported.&lt;/p&gt;

&lt;p&gt;These flaws are not in Symfony or Laravel, but in the PHP interpreter. No &lt;code&gt;composer update&lt;/code&gt; fixes them: you have to change PHP itself. Here is how I did it for apprendre.forelse.fr, which runs on Symfony and FrankenPHP in Docker.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Know which PHP actually runs
&lt;/h2&gt;

&lt;p&gt;First question: which version of PHP is serving requests? Not the one on my laptop, nor the one written in &lt;code&gt;composer.json&lt;/code&gt;, but the one in the production container.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker compose exec app php -v


PHP 8.4.26 (cli) (built: Oct 6 2026 01:25:42) (ZTS)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PHP 8.4.26: the latest version published at that point, and yet vulnerable. The fix did not exist yet.&lt;/p&gt;

&lt;p&gt;My &lt;code&gt;Dockerfile&lt;/code&gt; starts from &lt;code&gt;dunglas/frankenphp:1-php8.4&lt;/code&gt;. That tag follows new releases of PHP 8.4, which gives the impression of always being up to date. It is not: the image is frozen at the moment it is built. Mine dated from 16 October. Until I rebuild it, it keeps the PHP from that day, even after FrankenPHP publishes a fixed image.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A floating tag (&lt;code&gt;php8.4&lt;/code&gt;, &lt;code&gt;latest&lt;/code&gt;) only gives you the latest version at the moment you rebuild the image.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  2. A stopgap while waiting for the fix
&lt;/h2&gt;

&lt;p&gt;The official fix was announced for 21 October at 14:00 UTC. In the meantime, I reduced the attack surface. Both flaws are memory issues. The first (CVE-2026-109618) is a memory corruption: a multipart request crafted to exhaust memory abruptly aborts request processing. The second (CVE-2026-109619) is a use-after-free between two requests served by the same worker. According to Rapid7's analysis, its first known trigger is upload progress tracking, &lt;code&gt;session.upload_progress&lt;/code&gt;. It is enabled by default, and usable without even calling &lt;code&gt;session_start()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My application does not use that tracking. So I turned it off in &lt;code&gt;php.ini&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="n"&gt;session&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="n"&gt;upload\_progress&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Off&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This file ships inside the image: the change only reaches production after a rebuild and a redeploy, like any other config. Then you check it was applied (in the FrankenPHP image, the CLI and the server read the same &lt;code&gt;php.ini&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;app php &lt;span class="nt"&gt;-i&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;upload_progress.enabled

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not a fix. It closes the first known trigger of the second flaw, but not the memory corruption of the first. One unused option switched off is one less open door until the real fix is in.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Rebuild the image with the fixed PHP
&lt;/h2&gt;

&lt;p&gt;The fix shipped on 21 October 2026: PHP 8.4.27 for my branch (and 8.2.35, 8.3.36, 8.5.12 for the others). FrankenPHP followed the same evening with its 1.13.2 release, whose images already bundle these fixed PHP versions.&lt;/p&gt;

&lt;p&gt;My &lt;code&gt;Dockerfile&lt;/code&gt; started from &lt;code&gt;dunglas/frankenphp:1-php8.4&lt;/code&gt;, a floating tag. I took the opportunity to pin an exact version: &lt;code&gt;dunglas/frankenphp:1.13.2-php8.5&lt;/code&gt;, which bundles PHP 8.5.12, fixed as well. To stay on PHP 8.4 without switching branches, &lt;code&gt;1.13.2-php8.4&lt;/code&gt; gives you 8.4.27.&lt;/p&gt;

&lt;p&gt;That leaves rebuilding the image. Since hosted CI runners are fresh every time, the &lt;code&gt;FROM&lt;/code&gt; re-pulls the base image. But on a machine that already has an image cached, Docker would reuse it: to be safe, force a re-pull of the base image.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;--pull&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; my-image &lt;span class="nb"&gt;.&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In CI with &lt;code&gt;docker/build-push-action&lt;/code&gt;, the equivalent is the &lt;code&gt;pull: true&lt;/code&gt; option. The layer cache (&lt;code&gt;cache-from&lt;/code&gt;) is not a problem here: its key depends on the base image's digest, which has changed, so every layer that builds on it is rebuilt.&lt;/p&gt;

&lt;p&gt;Then redeploy, and check again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;app php &lt;span class="nt"&gt;-v&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The version shown should now be 8.5.12. As long as it says 8.4.26, the old image is still running.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A safety net for next time
&lt;/h2&gt;

&lt;p&gt;What bothered me most in this episode was not knowing right away that my production server was still running a vulnerable PHP. &lt;code&gt;composer audit&lt;/code&gt; only looks at PHP dependencies, not the interpreter. So I added a safety net to my CI: when I cut a release (a tag), a job reads the PHP actually bundled in the image and compares it to the latest published version of its branch. If it is behind, the job raises a clearly visible warning on the release.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# PHP bundled in the image we are about to publish&lt;/span&gt;
&lt;span class="nv"&gt;installed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--entrypoint&lt;/span&gt; php &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMAGE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'echo PHP\_VERSION;'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;branch&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;installed&lt;/span&gt;&lt;span class="p"&gt;%.\*&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; &lt;span class="c"&gt;# 8.5.12 -&amp;gt; 8.5&lt;/span&gt;

&lt;span class="c"&gt;# Latest published version of that branch, per php.net&lt;/span&gt;
&lt;span class="nv"&gt;latest&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="s2"&gt;"https://www.php.net/releases/?json&amp;amp;version=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;branch&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;max=1"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'keys\_unsorted[0]'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;dpkg &lt;span class="nt"&gt;--compare-versions&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$installed&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; lt &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$latest&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"::warning::PHP &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;installed&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is behind &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;latest&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, consider bumping the base image."&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wired it so that it only runs when cutting a release, not on other builds. I chose a warning rather than a failure: being one patch behind should not stop me from shipping, but it must be visible. This is not protection against the flaws themselves: it is a reminder, at the right moment, that the base image has fallen behind. Paired with a tool that watches the base image (Renovate, Dependabot), the window between "a fixed PHP exists" and "my production runs it" closes on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. What I take away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Watch PHP, not just Composer.&lt;/strong&gt; &lt;code&gt;composer audit&lt;/code&gt; does not see flaws in the interpreter. Hence the CI safety net from section 4, which compares the image's PHP to the latest published version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rebuild regularly, and track the base image.&lt;/strong&gt; An image you never rebuild quietly goes stale. And if you pin an exact base version, which is safer for reproducibility, you need a tool like Renovate or Dependabot to get notified of new versions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn off what you don't use.&lt;/strong&gt; &lt;code&gt;session.upload_progress&lt;/code&gt; is enabled by default, while few applications use it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check inside the container.&lt;/strong&gt; The only version that matters is the one &lt;code&gt;php -v&lt;/code&gt; returns in production.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.rapid7.com/blog/post/etr-cve-2026-109618-and-cve-2026-109619-critical-unauthenticated-remote-code-execution-in-php/" rel="noopener noreferrer"&gt;Rapid7 analysis of both CVEs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/php/frankenphp/releases/tag/v1.13.2" rel="noopener noreferrer"&gt;FrankenPHP v1.13.2 (release fixing both CVEs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://patchstack.com/articles/php-major-vulnerability-alert-10-2026/" rel="noopener noreferrer"&gt;Patchstack&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://watchtowr.com/intelligence/php-cve-2026-109618-faq/" rel="noopener noreferrer"&gt;watchTowr&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>php</category>
      <category>symfony</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Event bursts: debounce, uniqueness or rate limiting, which should you use?</title>
      <dc:creator>Arnaud POINTET</dc:creator>
      <pubDate>Wed, 21 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/oipnet/event-bursts-debounce-uniqueness-or-rate-limiting-which-should-you-use-4if8</link>
      <guid>https://dev.to/oipnet/event-bursts-debounce-uniqueness-or-rate-limiting-which-should-you-use-4if8</guid>
      <description>&lt;p&gt;A product gets edited three times in ten seconds, because someone fixes a typo, then the price, then the photo. An application that reindexes the product in the search engine on every change does the same expensive work three times. And if it goes about it the wrong way, it also indexes the wrong version.&lt;/p&gt;

&lt;p&gt;That's the topic of the &lt;a href="https://apprendre.forelse.fr/en/practice/laravel-13-26-debounce-for?utm_source=rss&amp;amp;utm_medium=feed&amp;amp;utm_campaign=laravel-event-bursts-debounce" rel="noopener noreferrer"&gt;Practice exercise on &lt;code&gt;#[DebounceFor]&lt;/code&gt;&lt;/a&gt;, an attribute that shipped with Laravel 13.26. The exercise starts from a listener guarded by &lt;code&gt;ShouldBeUnique&lt;/code&gt;, which lets a subtle bug slip through, and fixes it with the new attribute. This article picks up from there, looks at how the mechanism works, then compares the different ways to absorb a burst.&lt;/p&gt;

&lt;h2&gt;
  
  
  The starting bug: unique, but stale
&lt;/h2&gt;

&lt;p&gt;The listener in the exercise looks like something we write all the time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UpdateSearchIndex&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ShouldBeUnique&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$uniqueFor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&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="nf"&gt;\_\_construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;SearchIndex&lt;/span&gt; &lt;span class="nv"&gt;$index&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;uniqueId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ProductUpdated&lt;/span&gt; &lt;span class="nv"&gt;$event&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;productId&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;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ProductUpdated&lt;/span&gt; &lt;span class="nv"&gt;$event&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="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&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;Three edits back to back, a single indexing run: the count test passes. But &lt;code&gt;ShouldBeUnique&lt;/code&gt; takes a lock on the &lt;strong&gt;first&lt;/strong&gt; dispatch and discards the ones that follow for as long as that lock exists, that is, until processing finishes or at most &lt;code&gt;uniqueFor&lt;/code&gt; seconds. So it's the first event that gets processed, with the first name. The index shows "Keyboard" while the product has been called "Mechanical keyboard" for twenty seconds, and nothing is ever going to fix it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ShouldBeUnique&lt;/code&gt; answers the question "is this work already scheduled?" Here, the real question is "what is the &lt;strong&gt;latest&lt;/strong&gt; state to process?" Those are two different needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: wait for the burst to end
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[DebounceFor(30)]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UpdateSearchIndex&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&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="nf"&gt;\_\_construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;SearchIndex&lt;/span&gt; &lt;span class="nv"&gt;$index&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;debounceId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ProductUpdated&lt;/span&gt; &lt;span class="nv"&gt;$event&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;productId&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;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ProductUpdated&lt;/span&gt; &lt;span class="nv"&gt;$event&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="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&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 term comes from electronics, where a physical button "bounces" and sends several signals for a single press. You wait for the signal to settle before reading it. Front-end developers know it well: it's the search box that only queries the server once you've stopped typing.&lt;/p&gt;

&lt;p&gt;Laravel in fact refuses to let a listener be both debounced and &lt;code&gt;ShouldBeUnique&lt;/code&gt;: it throws a &lt;code&gt;LogicException&lt;/code&gt;. The two strategies are incompatible by design.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;The source code is short and worth reading, because it sheds light on the mechanism's limits.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;On every dispatch&lt;/strong&gt; , Laravel writes a random token to the cache, under a key derived from the listener and the &lt;code&gt;debounceId&lt;/code&gt;: the current "owner". The job is queued with a 30-second delay and carries that token with it. Watch out: if the listener already sets its own delay (&lt;code&gt;withDelay()&lt;/code&gt; or &lt;code&gt;#[Delay]&lt;/code&gt;), that delay takes precedence over the &lt;code&gt;DebounceFor&lt;/code&gt; one, without any warning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A new dispatch&lt;/strong&gt; for the same product writes a new token to the cache, replacing the old one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When a job comes due&lt;/strong&gt; , the worker compares its token with the one in the cache. If they differ, a more recent dispatch exists: the job is deleted without running, and Laravel fires a &lt;code&gt;JobDebounced&lt;/code&gt; event. Otherwise, the job runs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result: only the job from the last dispatch goes through, 30 seconds after it. This mechanism has several consequences.&lt;/p&gt;

&lt;h3&gt;
  
  
  Jobs are created, then thrown away
&lt;/h3&gt;

&lt;p&gt;Debouncing doesn't save you anything on the queue: three edits still create three jobs, and the worker still dequeues three. What you save is the &lt;strong&gt;work&lt;/strong&gt; : two of the three jobs stop after a single cache read. For indexing that costs a network call to Elasticsearch or Meilisearch, that's what matters most. But for an import of 50,000 products, that's still 50,000 jobs in the queue.&lt;/p&gt;

&lt;h3&gt;
  
  
  The cache becomes a coordination point
&lt;/h3&gt;

&lt;p&gt;The whole decision rests on the cache. Across several servers, it has to be shared (Redis, Memcached, database): a &lt;code&gt;file&lt;/code&gt; or &lt;code&gt;array&lt;/code&gt; cache local to each machine would break the mechanism entirely. The listener can point to a cache store other than the default one with a &lt;code&gt;debounceVia()&lt;/code&gt; method.&lt;/p&gt;

&lt;p&gt;And if the key disappears from the cache (eviction under memory pressure, a Redis restart without persistence), Laravel chooses to &lt;strong&gt;run&lt;/strong&gt; the job rather than lose it. That's the right call, but it means duplicate processing is still possible. Debouncing reduces duplicates; it doesn't rule them out.&lt;/p&gt;

&lt;p&gt;The most common case isn't eviction, by the way: the token lives for ten times the window, and at least five minutes. If the queue falls further behind than that, the key expires before the jobs are dequeued, and &lt;strong&gt;all&lt;/strong&gt; the jobs in the burst run. So a clogged queue, precisely on the day there are lots of edits, loses its debounce.&lt;/p&gt;

&lt;h3&gt;
  
  
  The last event gets processed, with the delay on top
&lt;/h3&gt;

&lt;p&gt;Work starts at the earliest 30 seconds after the &lt;strong&gt;last&lt;/strong&gt; edit. For a search index, that's acceptable. For a "your order has shipped" notification, much less so. Picking the window means trading off the number of runs you avoid against data freshness.&lt;/p&gt;

&lt;h2&gt;
  
  
  The continuous-stream trap: &lt;code&gt;maxWait&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Picture a product whose stock is updated every ten seconds by a warehouse. With a 30-second window, each update pushes back the previous one: the burst never ends, and the product is &lt;strong&gt;never&lt;/strong&gt; reindexed.&lt;/p&gt;

&lt;p&gt;That's what &lt;code&gt;maxWait&lt;/code&gt; is for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[DebounceFor(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nd"&gt;,&lt;/span&gt; &lt;span class="nd"&gt;maxWait:&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="nd"&gt;)]&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Laravel records the time of the burst's first dispatch. If a dispatch arrives when 120 seconds have already passed, it's queued with no delay, and that counter resets to zero. The delay is therefore capped at roughly two minutes, with one caveat: &lt;code&gt;maxWait&lt;/code&gt; guarantees a dispatch with no delay, not an execution. If the queue itself is lagging and a new dispatch comes in before the worker has picked up that job, its token gets replaced, and it's thrown away like the others. (Since Laravel 13.27, the &lt;code&gt;maxWait&lt;/code&gt; window also resets when a debounced job runs.)&lt;/p&gt;

&lt;p&gt;As soon as an event can arrive continuously, I add a &lt;code&gt;maxWait&lt;/code&gt;. Without it, a debounce can indefinitely delay processing of a very active object, and that's often the most important object.&lt;/p&gt;

&lt;h2&gt;
  
  
  Process the state, not the event
&lt;/h2&gt;

&lt;p&gt;There's one weakness left in the exercise's listener, which the exercise doesn't ask you to fix: it indexes &lt;strong&gt;the name carried by the event&lt;/strong&gt;. As long as the last event processed really is the most recent one, all is well. But between a retry after an error, a duplicate job after a cache eviction and two workers draining the queue in a slightly different order, "the last event" is a shakier notion than it looks.&lt;/p&gt;

&lt;p&gt;In production, I'd rather have the listener reload the current state:&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;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ProductUpdated&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Product&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$product&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&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 event no longer says "here's the new name", but "this product changed, go take a look". Processing becomes &lt;strong&gt;idempotent&lt;/strong&gt; : running it twice, or out of order, produces the same index. Debouncing is then only there to save work, no longer to guarantee data correctness. That's a much more robust division of responsibilities.&lt;/p&gt;

&lt;p&gt;A corollary: an event dispatched inside a database transaction must go out &lt;strong&gt;after&lt;/strong&gt; the commit (&lt;code&gt;ShouldDispatchAfterCommit&lt;/code&gt; on the event, or &lt;code&gt;after_commit&lt;/code&gt; on the queue connection). Otherwise, the worker may reread the database before the change is visible. With a 30-second delay, that's unlikely, but a slow transaction is still possible, and hitting &lt;code&gt;maxWait&lt;/code&gt; sends the job out with no delay. When dispatch is deferred until after the commit, Laravel also releases the debounce lock if the transaction is rolled back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debounce, uniqueness, rate limiting: the table
&lt;/h2&gt;

&lt;p&gt;There are several related tools for absorbing a burst, and they don't answer the same question:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it keeps&lt;/th&gt;
&lt;th&gt;When to use it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;#[DebounceFor]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the &lt;strong&gt;last&lt;/strong&gt; dispatch, after a quiet period&lt;/td&gt;
&lt;td&gt;syncing state that changes in bursts: search index, cache, export to a third-party service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ShouldBeUnique&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the &lt;strong&gt;first&lt;/strong&gt; dispatch; later ones are ignored until it's processed&lt;/td&gt;
&lt;td&gt;work that doesn't depend on the event's payload: "recalculate the statistics", "regenerate the sitemap"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ShouldBeUniqueUntilProcessing&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the first dispatch, but a new dispatch is let through as soon as processing starts&lt;/td&gt;
&lt;td&gt;same as above, when a change made during processing must also be taken into account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;WithoutOverlapping&lt;/code&gt; middleware&lt;/td&gt;
&lt;td&gt;every dispatch, but &lt;strong&gt;never two at once&lt;/strong&gt; for the same key&lt;/td&gt;
&lt;td&gt;protecting a resource that can't handle concurrency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;RateLimited&lt;/code&gt; middleware&lt;/td&gt;
&lt;td&gt;every dispatch, &lt;strong&gt;spread out&lt;/strong&gt; over time&lt;/td&gt;
&lt;td&gt;staying within an external API's quota&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A note on the third row: if the listener reloads the state from the database, as in the previous section, &lt;code&gt;ShouldBeUniqueUntilProcessing&lt;/code&gt; becomes a credible alternative to debouncing, with no artificial delay. The first edit triggers processing right away. Later ones, if they arrive during that processing, trigger a new run that will reload the final state. The price: more runs than with a debounce, but no added latency.&lt;/p&gt;

&lt;h2&gt;
  
  
  When per-object debouncing is no longer enough
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;debounceId&lt;/code&gt; groups dispatches &lt;strong&gt;for the same object&lt;/strong&gt;. A hundred products edited at the same time by an import means a hundred indexing runs, even with a perfect debounce. Yet a search engine indexes a hundred documents in one go far better than one document a hundred times.&lt;/p&gt;

&lt;p&gt;For that case, you have to change approach: each edit adds the product ID to a set (a Redis &lt;code&gt;SADD&lt;/code&gt;, a table of products to reindex), and a single job, triggered on a schedule or itself debounced with a fixed key, drains the set and indexes in batches. You're no longer debouncing events, you're &lt;strong&gt;accumulating&lt;/strong&gt; changes. It's more code, but it's what holds up under a massive import.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing a debounce
&lt;/h2&gt;

&lt;p&gt;One last point, about how the exercise tests it. &lt;code&gt;Queue::fake()&lt;/code&gt; would be useless here: it records dispatches without running them, whereas all the behavior happens at execution time. So the exercise's tests use a real queue (the &lt;code&gt;database&lt;/code&gt; driver), move the clock forward and start a real worker:&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;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$edits&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$productId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;ProductUpdated&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$productId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$name&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;travel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;travel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nc"&gt;Artisan&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'queue:work'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'--stop-when-empty'&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="s1"&gt;'--sleep'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then they check what the system produced: one indexing run per product, with the latest name. It's slower than a fake, but it's the only way to test what actually matters. In production, I also recommend listening for &lt;code&gt;JobDebounced&lt;/code&gt; to count discarded jobs: if that counter stays at zero, the debounce is pointless. If it's very high, the window may be too long for your users.&lt;/p&gt;

&lt;h2&gt;
  
  
  My takeaways
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;#[DebounceFor]&lt;/code&gt; fixes a genuine misuse: we were using &lt;code&gt;ShouldBeUnique&lt;/code&gt; to absorb bursts, and indexing the wrong version. The new attribute does what we actually wanted, in a few lines.&lt;/p&gt;

&lt;p&gt;But a debounce is still an optimization, not a guarantee. It doesn't shrink the queue, it depends on the cache, it adds latency, and it lets duplicates through when the cache falters. Robustness comes from elsewhere: processing that reloads the state and can run twice without harm. The debounce then makes that processing cheaper.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://apprendre.forelse.fr/en/practice/laravel-13-26-debounce-for?utm_source=rss&amp;amp;utm_medium=feed&amp;amp;utm_campaign=laravel-event-bursts-debounce" rel="noopener noreferrer"&gt;exercise&lt;/a&gt; runs in the browser, with a real Laravel 13 and a real queue: you see the &lt;code&gt;ShouldBeUnique&lt;/code&gt; bug, then its fix.&lt;/p&gt;

</description>
      <category>php</category>
      <category>laravel</category>
      <category>queue</category>
      <category>performance</category>
    </item>
    <item>
      <title>Versioning a Symfony API without duplicating your classes</title>
      <dc:creator>Arnaud POINTET</dc:creator>
      <pubDate>Fri, 16 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/oipnet/versioning-a-symfony-api-without-duplicating-your-classes-13ie</link>
      <guid>https://dev.to/oipnet/versioning-a-symfony-api-without-duplicating-your-classes-13ie</guid>
      <description>&lt;p&gt;An API that ships a new version cannot drop the old one overnight. Partners, a mobile app that hasn't been updated, a script written two years ago: they all still read v1. The data is the same, but the field names and the shape of the response have changed.&lt;/p&gt;

&lt;p&gt;With the Symfony Serializer, groups already chose &lt;strong&gt;which&lt;/strong&gt; properties are output. They said nothing about &lt;strong&gt;what&lt;/strong&gt; they are called. Symfony 8.2 (still in development) fills that gap: &lt;code&gt;#[SerializedName]&lt;/code&gt; and &lt;code&gt;#[SerializedPath]&lt;/code&gt; now accept a &lt;code&gt;groups&lt;/code&gt; argument, and a new context option, &lt;code&gt;ignored_groups&lt;/code&gt;, leaves a group out for a single response.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://apprendre.forelse.fr/en/practice/symfony-8-2-groupes-serialisation?utm_source=rss&amp;amp;utm_medium=feed&amp;amp;utm_campaign=versioning-a-symfony-api-without-duplicating-classes" rel="noopener noreferrer"&gt;Practice exercise&lt;/a&gt; that showcases these features is about deleting loops. This article takes a step back: how two versions used to be served, what the new approach changes, and the pitfalls to know before adopting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The starting point
&lt;/h2&gt;

&lt;p&gt;A bookshop publishes its books in three places. v1, which partners still read, answers with a flat shape and its old names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"isbn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"978-0-15-601219-5"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The Little Prince"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"price\_cents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;790&lt;/span&gt;&lt;span class="w"&gt; &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;v2 renamed the title and nests the price with its currency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"isbn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"978-0-15-601219-5"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The Little Prince"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"cents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;790&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"EUR"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &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;The back office reads the same shape as v2, plus the stock. The &lt;code&gt;Livre&lt;/code&gt; class carries &lt;code&gt;v1&lt;/code&gt;, &lt;code&gt;v2&lt;/code&gt; and &lt;code&gt;internal&lt;/code&gt; groups, but only one way of naming each property.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we did it until now
&lt;/h2&gt;

&lt;h3&gt;
  
  
  One class per version
&lt;/h3&gt;

&lt;p&gt;The most explicit solution: a &lt;code&gt;BookV1&lt;/code&gt; and a &lt;code&gt;BookV2&lt;/code&gt;, each with its own names, and a bit of code to map the entity to one or the other. Each version's contract can be read at a glance, and that is a real advantage.&lt;/p&gt;

&lt;p&gt;The cost shows up over time. Every field added to v2 raises the question for v1. Every format fix happens in two places. And a third version multiplies all of it. For versions that differ by three field names, that is a lot of code to maintain.&lt;/p&gt;

&lt;h3&gt;
  
  
  A loop after the serializer
&lt;/h3&gt;

&lt;p&gt;The most common solution, and the exercise's before it is fixed: serialize with the version's group, then patch the array.&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;$books&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;normalizer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;catalogue&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;books&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'json'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'groups'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'v1'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$book&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$books&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'isbn'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$book&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'isbn'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$book&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'price\_cents'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$book&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'price'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'cents'&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;It works, but the v1 contract now lives in a controller, far from the class it describes. The day someone adds a field to the &lt;code&gt;v1&lt;/code&gt; group, it doesn't come out: the loop doesn't know about it. And the loop only works one way. If v1 also accepts writes, you need a second loop, in reverse, for deserialization.&lt;/p&gt;

&lt;h3&gt;
  
  
  A custom normalizer or name converter
&lt;/h3&gt;

&lt;p&gt;The most "Symfony" solution: a normalizer that decorates &lt;code&gt;ObjectNormalizer&lt;/code&gt; for books in v1, or a &lt;em&gt;name converter&lt;/em&gt; that renames according to the context. Renaming then works both ways, and it stays out of the controller.&lt;/p&gt;

&lt;p&gt;In practice, I see these classes age badly. The name converter is global to the Serializer: it receives every property of every class, and has to check the class and the version itself. The decorated normalizer has to find its place among the others, and must not forget nested objects. That is a lot of infrastructure to express "in v1, &lt;code&gt;title&lt;/code&gt; is called &lt;code&gt;name&lt;/code&gt;".&lt;/p&gt;

&lt;h2&gt;
  
  
  What Symfony 8.2 changes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  One name per group
&lt;/h3&gt;

&lt;p&gt;Both attributes become repeatable and take a &lt;code&gt;groups&lt;/code&gt; argument:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#[Groups(['v1', 'v2'])]
#[SerializedName('name', groups: 'v1')]
public readonly string $title,

#[Groups(['v1', 'v2'])]
#[SerializedName('price\_cents', groups: 'v1')]
#[SerializedPath('[price][cents]', groups: 'v2')]
public readonly int $priceInCents,

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Serialized with the &lt;code&gt;v1&lt;/code&gt; group, the title comes out as &lt;code&gt;name&lt;/code&gt;; with &lt;code&gt;v2&lt;/code&gt;, it keeps its PHP name. The price comes out flat as &lt;code&gt;price_cents&lt;/code&gt; in v1, and nested as &lt;code&gt;price.cents&lt;/code&gt; in v2. Each version's contract is back on the class, and the controller shrinks to one line:&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;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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;normalizer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;catalogue&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;books&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'json'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'groups'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'v1'&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The renaming also applies to deserialization: a v1 body that sends &lt;code&gt;name&lt;/code&gt; does fill &lt;code&gt;$title&lt;/code&gt;, with no second loop. YAML and XML configuration follow suit, with a &lt;code&gt;serialized&lt;/code&gt; key and a &lt;code&gt;&amp;lt;serialized&amp;gt;&lt;/code&gt; element.&lt;/p&gt;

&lt;h3&gt;
  
  
  Leaving a group out
&lt;/h3&gt;

&lt;p&gt;The stock belongs to the &lt;code&gt;v2&lt;/code&gt; and &lt;code&gt;internal&lt;/code&gt; groups. The back office serializes &lt;code&gt;v2&lt;/code&gt; and sees it; the public API serializes &lt;code&gt;v2&lt;/code&gt; too, and must not see it. Before 8.2, you needed an &lt;code&gt;unset()&lt;/code&gt; afterwards, or an extra group on every other property. The &lt;code&gt;AbstractNormalizer::IGNORED_GROUPS&lt;/code&gt; context option settles it:&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="n"&gt;normalizer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;catalogue&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;books&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'json'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;AbstractNormalizer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;GROUPS&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'v2'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;AbstractNormalizer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;IGNORED\_GROUPS&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'internal'&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;A property that belongs to an ignored group is not output, &lt;strong&gt;even if it also belongs to a requested group&lt;/strong&gt;. Exclusion wins. With a context builder, it's &lt;code&gt;withIgnoredGroups('internal')&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pitfalls
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The ungrouped name is the fallback
&lt;/h3&gt;

&lt;p&gt;A &lt;code&gt;#[SerializedName]&lt;/code&gt; without a &lt;code&gt;groups&lt;/code&gt; argument applies to every group. Combined with a per-group name, it becomes the fallback: the group's name wins when that group is requested, otherwise the ungrouped name applies.&lt;/p&gt;

&lt;p&gt;Handy, but it backfires quickly. In the exercise, the price started with an ungrouped &lt;code&gt;#[SerializedPath('[price][cents]')]&lt;/code&gt;. Add a &lt;code&gt;#[SerializedName('price_cents', groups: 'v1')]&lt;/code&gt; without touching the path, and v1 finds two answers: a name for its group, and a fallback path. When serializing, the path silently wins: the price stays under &lt;code&gt;price.cents&lt;/code&gt;, and &lt;code&gt;price_cents&lt;/code&gt; never appears. When deserializing, the Serializer refuses to choose and throws a &lt;code&gt;LogicException&lt;/code&gt; ("Found SerializedName and SerializedPath attributes"). Either way, the path has to be tied to the &lt;code&gt;v2&lt;/code&gt; group only.&lt;/p&gt;

&lt;p&gt;The silent case is the most dangerous one. My rule: as soon as a property has a name per group, I give a group to every one of its names and paths. The implicit fallback is the first thing you overlook when rereading the class.&lt;/p&gt;

&lt;h3&gt;
  
  
  Several groups requested: the declaration decides
&lt;/h3&gt;

&lt;p&gt;What happens if you request &lt;code&gt;['v1', 'v2']&lt;/code&gt; and the property has a name for each? The first &lt;strong&gt;declared&lt;/strong&gt; name whose group is requested wins. The order of the groups in the context doesn't matter. This is deliberate, so that two controllers listing the groups in a different order get the same response. But it means you can't pick a version by putting its group in front of the others. One response is one version, so one version group.&lt;/p&gt;

&lt;h3&gt;
  
  
  A property without a group disappears
&lt;/h3&gt;

&lt;p&gt;This isn't new, but it gets rediscovered every time groups are introduced: as soon as the context requests groups, a property without &lt;code&gt;#[Groups]&lt;/code&gt; is no longer output. Symfony 8.2 adds an option to get around this without annotating the whole class: with &lt;code&gt;AbstractNormalizer::ENABLE_DEFAULT_GROUPS&lt;/code&gt;, ungrouped properties join the &lt;code&gt;Default&lt;/code&gt; group and the class's short name (&lt;code&gt;Livre&lt;/code&gt;), provided you request one of them in the context, for example &lt;code&gt;['v1', 'Default']&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;ignored_groups&lt;/code&gt; is not &lt;code&gt;#[Ignore]&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Both remove properties, but not in the same place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;#[Ignore]&lt;/code&gt; sits on the property, in the class. The property is never output, anywhere, and never read in.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ignored_groups&lt;/code&gt; is passed in the context. It only applies to that call: the back office, which doesn't pass it, still sees the stock.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ignored_attributes&lt;/code&gt;, which has been around for a long time, removes properties by name. It works, but every controller has to know the list, and a property added to the &lt;code&gt;internal&lt;/code&gt; group later won't be left out. With &lt;code&gt;ignored_groups&lt;/code&gt;, the decision stays on the class.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One detail that matters for an API: the exclusion also applies to deserialization. A &lt;code&gt;stock&lt;/code&gt; sent by a public API client is ignored, like an unknown key. Be careful if the property is a required constructor argument, as in the exercise: deserialization then fails for lack of a value. Give it a default value, or keep that property out of the constructor.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test the contract on the response
&lt;/h3&gt;

&lt;p&gt;The risk with these attributes is that a change to the class alters a version without anyone noticing. So I test each version against the exact response, not against the presence of a few keys:&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;assertSame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'isbn'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'978-0-15-601219-5'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'The Little Prince'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'price\_cents'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;790&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/api/v1/books'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&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;An &lt;code&gt;assertSame&lt;/code&gt; on the whole array fails if a field appears, disappears or gets renamed. That is exactly what a v1 partner would notice. The exercise's tests do the same for v2 and the back office.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to keep two classes
&lt;/h2&gt;

&lt;p&gt;Per-group names fit when the versions share the same model and only differ in shape: names, nesting, a few fields more or less. That is the most common case, and it's where the new feature removes the most code.&lt;/p&gt;

&lt;p&gt;I keep separate classes when the versions diverge in substance: a field computed differently, a resource split in two, validation that is no longer the same. Stacking attributes for each version on a single property then becomes unreadable, and a dedicated class becomes the clear solution again. A good indicator: if a property carries more than two different names, the question is worth asking.&lt;/p&gt;

&lt;h2&gt;
  
  
  My takeaway
&lt;/h2&gt;

&lt;p&gt;Symfony 8.2 doesn't change how to think about groups; it completes what they could do. A group already chose a version's fields; now it also gives their names and shape. And &lt;code&gt;ignored_groups&lt;/code&gt; replaces the &lt;code&gt;unset()&lt;/code&gt; calls lingering in controllers. Each version's contract goes back on the class it describes, and it holds both ways.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://apprendre.forelse.fr/en/practice/symfony-8-2-groupes-serialisation?utm_source=rss&amp;amp;utm_medium=feed&amp;amp;utm_campaign=versioning-a-symfony-api-without-duplicating-classes" rel="noopener noreferrer"&gt;exercise&lt;/a&gt; takes a few minutes in the browser, on a development version of Symfony 8.2. The three requests in the "Requests" tab must respond exactly as before, without a single loop in the controller.&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>api</category>
      <category>php</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Migrating files with zero downtime: what read-through does, and what it doesn't</title>
      <dc:creator>Arnaud POINTET</dc:creator>
      <pubDate>Wed, 14 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/oipnet/migrating-files-with-zero-downtime-what-read-through-does-and-what-it-doesnt-4k8l</link>
      <guid>https://dev.to/oipnet/migrating-files-with-zero-downtime-what-read-through-does-and-what-it-doesnt-4k8l</guid>
      <description>&lt;p&gt;Laravel 13.26 added a &lt;code&gt;read-through&lt;/code&gt; disk driver. In &lt;a href="https://apprendre.forelse.fr/en/practice/laravel-13-26-read-through?utm_source=rss&amp;amp;utm_medium=feed&amp;amp;utm_campaign=migrating-files-with-read-through" rel="noopener noreferrer"&gt;the Practice exercise&lt;/a&gt; that introduces it, a controller serves documents in the middle of a migration: it reads from the new storage, falls back to the old one, and copies files over along the way. The driver replaces those fifteen lines with three lines of configuration.&lt;/p&gt;

&lt;p&gt;That's good news, but a storage migration is more than that configuration. I've run several of them (local disk to S3, one bucket to another, one provider to another), and falling back on read is only one part of the job. This article starts from the exercise and goes all the way to the day you switch off the old storage.&lt;/p&gt;

&lt;h2&gt;
  
  
  The exercise: three lines of configuration
&lt;/h2&gt;

&lt;p&gt;Before, the controller did everything itself:&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;$new&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'new'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$new&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;asText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$new&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;$name&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$old&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'old'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$old&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$contents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$old&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;$name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$new&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$contents&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;asText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$contents&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With Laravel 13.26, you declare a disk that combines the two:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config/filesystems.php&lt;/span&gt;
&lt;span class="s1"&gt;'documents'&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;'driver'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'read-through'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'primary'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'new'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'fallback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'old'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the controller only knows about that one:&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;$documents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'documents'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;abort_unless&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$documents&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;asText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$documents&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;$name&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The win goes beyond fewer lines of code: the migration becomes a configuration detail, invisible to the rest of the application. The day it's over, you replace &lt;code&gt;documents&lt;/code&gt; with a plain S3 disk, and not a single call to &lt;code&gt;Storage::disk('documents')&lt;/code&gt; has to change.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the driver actually does
&lt;/h2&gt;

&lt;p&gt;The documentation is brief. The source code tells you more, and these details matter for what follows (the main operations, as of Laravel 13.26; &lt;code&gt;move()&lt;/code&gt; and &lt;code&gt;copy()&lt;/code&gt; on a file still sitting on the old disk only gained support in 13.27):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;get()&lt;/code&gt;, &lt;code&gt;readStream()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;reads &lt;code&gt;primary&lt;/code&gt;; otherwise reads &lt;code&gt;fallback&lt;/code&gt;, then &lt;strong&gt;writes a copy&lt;/strong&gt; to &lt;code&gt;primary&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;exists()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;true if the file is on &lt;strong&gt;either&lt;/strong&gt; disk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;put()&lt;/code&gt;, &lt;code&gt;writeStream()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;writes to &lt;code&gt;primary&lt;/code&gt; only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;delete()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;deletes from &lt;strong&gt;both&lt;/strong&gt; disks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;files()&lt;/code&gt;, &lt;code&gt;allFiles()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;list &lt;code&gt;primary&lt;/code&gt; only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;url()&lt;/code&gt;, &lt;code&gt;size()&lt;/code&gt;, &lt;code&gt;mimeType()&lt;/code&gt;, &lt;code&gt;lastModified()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;read from whichever disk has the file, &lt;strong&gt;without copying it&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two design choices are particularly well thought out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deletes hit both disks.&lt;/strong&gt; Otherwise, a file deleted from the new storage would linger on the old one, and the next read would bring it back. It's the classic bug in hand-rolled fallbacks, and I've seen it reach production: documents deleted at a customer's request (including GDPR erasure requests) that were back the next day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The copy re-checks before writing.&lt;/strong&gt; Between the moment the driver finds the file missing from &lt;code&gt;primary&lt;/code&gt; and the moment it copies it over, another request may have written a newer version. The driver checks again just before copying so it doesn't overwrite it. The race window doesn't disappear entirely (two operations are never atomic on object storage), but it becomes very narrow.&lt;/p&gt;

&lt;h2&gt;
  
  
  What read-through doesn't migrate
&lt;/h2&gt;

&lt;p&gt;The driver migrates what the application &lt;strong&gt;reads&lt;/strong&gt;. Everything else stays on the old disk, and that's where migrations get bogged down.&lt;/p&gt;

&lt;h3&gt;
  
  
  Files nobody ever reads
&lt;/h3&gt;

&lt;p&gt;An invoice from 2019, an inactive user's avatar, a forgotten attachment: most of an application's files are almost never read again. Read-through quickly migrates the hot files, then plateaus. It can happen that after three months only 20% of the files have moved, and the old storage is still indispensable.&lt;/p&gt;

&lt;p&gt;So you &lt;strong&gt;always&lt;/strong&gt; need a bulk backfill (see below). Read-through doesn't replace the copy: it lets you do it at your own pace, with no maintenance window, while the application is already running on the new storage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Files served by URL
&lt;/h3&gt;

&lt;p&gt;This is the most common trap. On S3, you rarely serve a file by reading it with PHP: you generate a URL, possibly a signed one, and the browser downloads straight from the bucket.&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;$documents&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;temporaryUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The driver does return a working URL: the one for the disk that holds the file. But it doesn't read the file, so &lt;strong&gt;it never copies it&lt;/strong&gt;. An application that serves all its files by URL migrates nothing at all with read-through, even though everything appears to work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Listings
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;files()&lt;/code&gt; and &lt;code&gt;allFiles()&lt;/code&gt; only list the new disk. So during the migration, an "All your documents" page that lists a folder only shows the documents already migrated. An export, a disk-usage calculation, a cleanup of orphaned files: anything that enumerates instead of reading a known path gives the wrong result.&lt;/p&gt;

&lt;p&gt;If the application lists files, the right answer often isn't a technical one: the database should know which files exist, not the storage. A migration is when that debt comes due.&lt;/p&gt;

&lt;h3&gt;
  
  
  Metadata
&lt;/h3&gt;

&lt;p&gt;The copy is a plain &lt;code&gt;write()&lt;/code&gt;. For &lt;code&gt;readStream()&lt;/code&gt;, the driver first buffers the whole file (in memory up to 2 MB, then in a temporary file) before writing it to the new disk and handing it back to you: so the first &lt;code&gt;Storage::download()&lt;/code&gt; of a large unmigrated file waits for the copy to finish before sending the first byte. The modification date becomes the date of the copy. Visibility (&lt;code&gt;public&lt;/code&gt; or &lt;code&gt;private&lt;/code&gt;) and HTTP headers (&lt;code&gt;Content-Type&lt;/code&gt;, &lt;code&gt;Cache-Control&lt;/code&gt;, &lt;code&gt;Content-Disposition&lt;/code&gt;) take the new disk's defaults. If your application relies on &lt;code&gt;lastModified()&lt;/code&gt; for HTTP caching, or on S3 headers set at upload time, check this before you migrate. It's not a flaw in the driver: it's true of almost any file copy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failed copies
&lt;/h3&gt;

&lt;p&gt;By default, if copying to the new disk fails (insufficient permissions, quota reached), the driver still returns the content it read from the old one. That's the right call for the user, who gets their file. But it means a migration can stall &lt;strong&gt;without making a sound&lt;/strong&gt;. The driver accepts a &lt;code&gt;throw_on_promotion_failure&lt;/code&gt; option to throw an exception in that case. Failing that, keep an eye on the volume of reads served by the old disk: if it isn't going down, something is stuck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seeing the migration through
&lt;/h2&gt;

&lt;p&gt;Here is the plan I follow, and read-through is only one step of it.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Write to the new disk, read from both
&lt;/h3&gt;

&lt;p&gt;You switch the application over to the &lt;code&gt;read-through&lt;/code&gt; disk. From that point on, every new or modified file goes straight to the new storage, and the old one no longer receives any writes. &lt;strong&gt;That's the real cutover&lt;/strong&gt; , and it happens with zero downtime.&lt;/p&gt;

&lt;p&gt;If several applications or several servers write to the old storage, they all have to switch before you move on to the next step. Otherwise, files keep landing on the old disk after the backfill has already gone past.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Backfill the rest in the background
&lt;/h3&gt;

&lt;p&gt;A command walks the old disk and copies whatever is missing. It must be able to stop and resume without starting over, and never overwrite a newer version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BackfillDocuments&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Command&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'documents:backfill'&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;handle&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$old&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'old'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$new&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'new'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$copied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$failures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$old&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;allFiles&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$new&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// already migrated, or rewritten since: leave it alone&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="nv"&gt;$stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$old&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;readStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nv"&gt;$ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$stream&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$new&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;writeStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$stream&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;\is_resource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$stream&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nb"&gt;fclose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$stream&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Failed: &lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="nv"&gt;$failures&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="nv"&gt;$copied&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$copied&lt;/span&gt;&lt;span class="s2"&gt; document(s) copied, &lt;/span&gt;&lt;span class="nv"&gt;$failures&lt;/span&gt;&lt;span class="s2"&gt; failure(s)."&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;$failures&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;FAILURE&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SUCCESS&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 &lt;code&gt;exists()&lt;/code&gt; check before each copy makes the command &lt;strong&gt;idempotent&lt;/strong&gt; : rerunning it after an interruption only copies what's missing. Streaming (&lt;code&gt;readStream()&lt;/code&gt;) keeps memory usage flat, even for files of several hundred megabytes. Failures are counted separately: without the &lt;code&gt;'throw' =&amp;gt; true&lt;/code&gt; option on the disks, &lt;code&gt;readStream()&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt; and &lt;code&gt;writeStream()&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt;, with no exception, and a command that doesn't check them would report success.&lt;/p&gt;

&lt;p&gt;With millions of files, &lt;code&gt;allFiles()&lt;/code&gt; becomes a problem in its own right: it loads the entire list into memory. At that point you're better off walking the table that references the files, in batches (&lt;code&gt;chunkById()&lt;/code&gt;), or spreading the work across a queue, one job per prefix or per batch of IDs.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Verify
&lt;/h3&gt;

&lt;p&gt;Before switching anything off, I check that the old storage is &lt;strong&gt;contained&lt;/strong&gt; in the new one: every file from the old storage exists on the new one, with the same size, and for a sample, the same checksum (&lt;code&gt;md5&lt;/code&gt;, or the ETag on S3 for files uploaded in a single part). Comparing total file counts would be meaningless: the new disk has received every file written since the migration started, and a delete removed the file from both sides. This is also the time to check the metadata that matters to the application.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Unplug the fallback, keep the old storage
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;documents&lt;/code&gt; disk goes back to being a plain S3 disk. If you want to play it safe, there's an intermediate step: keep the read-through disk with the &lt;code&gt;'copy' =&amp;gt; false&lt;/code&gt; option. The fallback still serves the odd forgotten file but no longer writes anything, and a rise in reads from the old storage points to a gap in the backfill. The old storage itself stays in place for a few weeks, &lt;strong&gt;read-only&lt;/strong&gt;. If a file is missing, you can still recover it. Keep a close eye on 404 logs for documents during this period.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Switch it off
&lt;/h3&gt;

&lt;p&gt;Only now. It's often the step people forget, and the old bucket is still being billed two years later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hidden cost of 404s
&lt;/h2&gt;

&lt;p&gt;One last detail, which matters on storage billed per request: a &lt;strong&gt;missing&lt;/strong&gt; file now costs two checks, one on each disk. If your application gets a lot of requests for files that don't exist (bots, old links, thumbnails not generated yet), the number of requests to your storage doubles for the entire migration. One more reason not to let it drag on.&lt;/p&gt;

&lt;h2&gt;
  
  
  My takeaways
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;read-through&lt;/code&gt; driver cleanly solves the trickiest part of a migration: cutting over with zero downtime, without any business code knowing a migration is underway. Its design choices (deleting from both disks, re-checking before copying) show that its authors have done this kind of migration before.&lt;/p&gt;

&lt;p&gt;But a migration doesn't finish on its own. Read-through is step 1 of 5. What makes the difference is the idempotent backfill, the verification, and the discipline to actually switch off the old storage.&lt;/p&gt;

&lt;p&gt;To see the driver in action, the &lt;a href="https://apprendre.forelse.fr/pratique/laravel-13-26-read-through?utm_source=rss&amp;amp;utm_medium=feed&amp;amp;utm_campaign=migrating-files-with-read-through" rel="noopener noreferrer"&gt;Practice exercise&lt;/a&gt; runs in the browser, with nothing to install: a real Laravel 13, two disks, and tests that check every behavior.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
