<?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: Eric Mollenthiel</title>
    <description>The latest articles on DEV Community by Eric Mollenthiel (@mollenthiel).</description>
    <link>https://dev.to/mollenthiel</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4038043%2F5bc4ed1d-d0e4-4591-860d-482f6002bb0f.webp</url>
      <title>DEV Community: Eric Mollenthiel</title>
      <link>https://dev.to/mollenthiel</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mollenthiel"/>
    <language>en</language>
    <item>
      <title>Postgres RLS in Symfony: with one tenant in the fixture, your isolation suite passes with no policy at all</title>
      <dc:creator>Eric Mollenthiel</dc:creator>
      <pubDate>Mon, 31 Aug 2026 08:33:49 +0000</pubDate>
      <link>https://dev.to/mollenthiel/postgres-rls-in-symfony-with-one-tenant-in-the-fixture-your-isolation-suite-passes-with-no-policy-3h42</link>
      <guid>https://dev.to/mollenthiel/postgres-rls-in-symfony-with-one-tenant-in-the-fixture-your-isolation-suite-passes-with-no-policy-3h42</guid>
      <description>&lt;p&gt;Ten days ago I wrote that the Postgres role which runs your migrations bypasses every row-level security policy you wrote. Mads Hansen replied with the obvious next step, and it was better than the article: run the same cross-tenant query suite twice, once as the serving role and once as the owner role, and compare.&lt;/p&gt;

&lt;p&gt;I did. It works, and I published the assertion. Then Marco took it apart with one question: what happens if you remove the violating state from the fixture? I measured that too, and it caught my own advice out. Equality is green in four databases out of five, and three of them are broken.&lt;/p&gt;

&lt;p&gt;So this is both halves. If you only take the first one home, you get a test that is green on a database with row-level security switched off.&lt;/p&gt;

&lt;h2&gt;
  
  
  The invariant is not "the owner run must fail"
&lt;/h2&gt;

&lt;p&gt;My first instinct was to assert that the owner run leaks: it sees rows from other tenants, so the assertion is &lt;code&gt;assertNotSame&lt;/code&gt;. That assertion is wrong, and it goes red on the day someone applies the correct fix.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ENABLE ROW LEVEL SECURITY&lt;/code&gt; leaves the table owner exempt. &lt;code&gt;FORCE ROW LEVEL SECURITY&lt;/code&gt; does not. After &lt;code&gt;FORCE&lt;/code&gt;, the owner sees exactly what the serving role sees. Measured on PostgreSQL 18.3, same fixture, same query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;### 1. ENABLE only: the owner ignores the policy
tenant 1 : serving=[1] owner=[1,2] DIVERGENT  &amp;lt;-- leak
tenant 2 : serving=[2] owner=[1,2] DIVERGENT  &amp;lt;-- leak

### 2. After FORCE ROW LEVEL SECURITY: both runs return the same set
tenant 1 : serving=[1] owner=[1] IDENTICAL
tenant 2 : serving=[2] owner=[2] IDENTICAL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the invariant that survives both worlds is: both runs return the same set. Not "the owner run fails". Write it as equality and it stays green when the schema is fixed, red when someone drops &lt;code&gt;FORCE&lt;/code&gt; in a migration.&lt;/p&gt;

&lt;p&gt;That is the assertion I gave in the thread, and it is where I would have stopped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Equality alone is green in three databases that are broken
&lt;/h2&gt;

&lt;p&gt;Marco's reply is the sentence I would keep from the whole exchange: the fixture is part of the security claim, not just test data. And its consequence, which is the part that made me go back to psql: if removing the violating state doesn't make the test fail, then the test was never actually proving the invariant.&lt;/p&gt;

&lt;p&gt;So I built the same table five different ways and ran three assertions side by side. Tenant 1 is the one being queried. &lt;strong&gt;Equality&lt;/strong&gt; is the double run above. &lt;strong&gt;Scoped&lt;/strong&gt; is the ordinary one everybody writes: the serving role returns only its own rows. &lt;strong&gt;Disjoint&lt;/strong&gt; is Marco's, made executable: set the context to the second tenant, assert that set is non-empty and does not intersect the first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                            equality  scoped  disjoint
2 tenants, no RLS at all      GREEN     RED     RED
2 tenants, ENABLE only        RED       GREEN   GREEN
2 tenants, FORCE              GREEN     GREEN   GREEN
1 tenant,  ENABLE only        GREEN     GREEN   RED
1 tenant,  no RLS at all      GREEN     GREEN   RED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only one row of five is a sound database. Read the columns rather than the rows.&lt;/p&gt;

&lt;p&gt;Equality is green in four rows and three of them are broken. Two runs that both return everything are still equal, so it never notices the world where there is no policy at all. It is red in exactly one row: the one my article was about.&lt;/p&gt;

&lt;p&gt;Scoped does not rescue it. Look at the bottom two rows: with a single organisation in the fixture, the serving role returns its one row whether the policy applies, or is inert because the app account owns the table, or does not exist at all. Both assertions are green, and one of those databases has the owner bypass sitting in it waiting for a second customer to sign up.&lt;/p&gt;

&lt;p&gt;That is the headline, and it is worth saying without the table: &lt;strong&gt;if your fixture has one tenant, your tenant-isolation test passes against a database with no row-level security on it.&lt;/strong&gt; The failure direction here is always too many rows, never zero, so a fixture that contains nothing to refuse cannot tell a working boundary from an absent one.&lt;/p&gt;

&lt;p&gt;Only the disjointness column notices that the fixture stopped being a violation. It is red in the three broken rows equality sleeps through, green in the sound one, and between them the pair covers all four broken worlds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disjointness has to stay inside the boundary it is checking
&lt;/h2&gt;

&lt;p&gt;The part I did not expect was how to write it without reaching for privilege.&lt;/p&gt;

&lt;p&gt;The obvious way to assert "there is still a second tenant in that table" is to go look with a connection that can see everything. Under &lt;code&gt;FORCE&lt;/code&gt; there is no such connection left, which is the whole point of &lt;code&gt;FORCE&lt;/code&gt;, and grabbing a superuser to check the fixture puts the check back outside the boundary you are trying to prove. You would be proving the policy from outside the policy.&lt;/p&gt;

&lt;p&gt;Querying as the second tenant is what stays inside it: same role, same policy, same request-scoped setting, nothing the application could not do itself. If tenant 2 comes back empty, the fixture has gone stale and the negative control is gone. If it comes back overlapping tenant 1, the boundary is not separating anything.&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;function&lt;/span&gt; &lt;span class="n"&gt;visibleIds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Connection&lt;/span&gt; &lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$tenantId&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;beginTransaction&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// set_config(..., true) is SET LOCAL, but it takes a bound parameter.&lt;/span&gt;
        &lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;executeStatement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SELECT set_config(?, ?, true)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'app.tenant_id'&lt;/span&gt;&lt;span class="p"&gt;,&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;$tenantId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="nv"&gt;$ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fetchFirstColumn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SELECT id FROM invoice ORDER BY id'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;rollBack&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="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'intval'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$ids&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$servingA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;visibleIds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$serving&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="nv"&gt;$ownerA&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;visibleIds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$owner&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="nv"&gt;$servingB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;visibleIds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$serving&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="c1"&gt;// Nobody is above the policy.&lt;/span&gt;
&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertSame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$servingA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$ownerA&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// There is a policy, and the fixture still contains something for it to refuse.&lt;/span&gt;
&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertNotSame&lt;/span&gt;&lt;span class="p"&gt;([],&lt;/span&gt; &lt;span class="nv"&gt;$servingB&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="nf"&gt;assertSame&lt;/span&gt;&lt;span class="p"&gt;([],&lt;/span&gt; &lt;span class="nb"&gt;array_intersect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$servingA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$servingB&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;set_config&lt;/code&gt; call is worth a line of its own. &lt;code&gt;SET LOCAL app.tenant_id = ?&lt;/code&gt; is not parameterisable: &lt;code&gt;SET&lt;/code&gt; takes a literal, so you end up interpolating a tenant id into SQL, in the one place where you least want to. &lt;code&gt;set_config(name, value, is_local)&lt;/code&gt; is the same thing as a function call, and DBAL binds it like any other query.&lt;/p&gt;

&lt;p&gt;And the run prints the ids rather than a count on purpose. &lt;code&gt;serving=[1] owner=[1,2]&lt;/code&gt; says something. &lt;code&gt;1 !== 2&lt;/code&gt; says the same thing with much less to go on when it fails in CI at 2am.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two ways "no tenant context" fails, and they are not alike
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;fresh connection, no context     : is_null=yes, rows=0            (silent)
reused connection, no context    : SQLSTATE[22P02] invalid input syntax for type integer: ""   (loud)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A connection that never had the setting returns &lt;code&gt;NULL&lt;/code&gt; from &lt;code&gt;current_setting('app.tenant_id', true)&lt;/code&gt;, the policy matches nothing, and you get zero rows in silence. A connection that had it set with &lt;code&gt;SET LOCAL&lt;/code&gt; inside a transaction gets the empty string back once that transaction ends, not &lt;code&gt;NULL&lt;/code&gt;, and the cast to &lt;code&gt;int&lt;/code&gt; raises &lt;code&gt;22P02&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The tempting fix is to make the policy tolerant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.tenant_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Measured: it works, and it is a trap. It turns the loud failure into the silent one. Zero rows, no error, on a connection that just lost its tenant context. If you have to choose, choose the version that raises: a query that explodes is an incident, a query that quietly returns nothing is a customer email three weeks later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The leak that hides under PHP-FPM
&lt;/h2&gt;

&lt;p&gt;Last one, and it is the reason any of this matters outside the test suite. &lt;code&gt;SET app.tenant_id = '2'&lt;/code&gt; without &lt;code&gt;LOCAL&lt;/code&gt;, in a transaction that commits, survives the transaction. Not the request: the connection.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'2'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.tenant_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;count&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- still_here | count&lt;/span&gt;
&lt;span class="c1"&gt;--  2         |     1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A rollback undoes it, a commit does not. Under PHP-FPM this is invisible, because the connection dies with the request and the next one starts clean. Put PgBouncer in transaction mode in front of it, or run the same code in a Messenger worker that holds a connection for hours, and the next transaction on that connection reads the previous tenant. The &lt;code&gt;LOCAL&lt;/code&gt; keyword is the entire difference, and no test that runs one request at a time will ever tell you it is missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;p&gt;Two bash scripts and two PHP files: they create a throwaway database and two roles, build the table in each of the five configurations, run the assertions under both connections, and drop everything on the way out. PostgreSQL 18.3, PHP 8.5.7, Doctrine DBAL. Nothing in them is specific to my schema, and I would rather you ran them against yours than believed my output. They came out of the multi-tenant kit I work on, &lt;a href="https://shipanvil.com" rel="noopener noreferrer"&gt;ShipAnvil&lt;/a&gt;, but there is no product in them: it is a table called &lt;code&gt;invoice&lt;/code&gt; with two rows.&lt;/p&gt;

&lt;p&gt;Thanks to Mads Hansen for the double run, and to Marco for the question that showed the double run was half an answer. If you are running RLS in a Symfony app, the cheapest thing you can do this week is open your isolation test and count the tenants in its fixture.&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>php</category>
      <category>postgres</category>
      <category>testing</category>
    </item>
    <item>
      <title>Row-level security in Symfony: the role that ran your migrations bypasses every policy you wrote</title>
      <dc:creator>Eric Mollenthiel</dc:creator>
      <pubDate>Fri, 21 Aug 2026 16:38:18 +0000</pubDate>
      <link>https://dev.to/mollenthiel/row-level-security-in-symfony-the-role-that-ran-your-migrations-bypasses-every-policy-you-wrote-34bi</link>
      <guid>https://dev.to/mollenthiel/row-level-security-in-symfony-the-role-that-ran-your-migrations-bypasses-every-policy-you-wrote-34bi</guid>
      <description>&lt;p&gt;&lt;a href="https://dev.to/mollenthiel/single-database-multi-tenancy-in-symfony-a-31-line-doctrine-filter-and-the-five-places-it-never-18pg#comments"&gt;A comment on my last article&lt;/a&gt; was better than the article. The subject was single-database multi-tenancy, and &lt;a class="mentioned-user" href="https://dev.to/to21as"&gt;@to21as&lt;/a&gt; argued that the predicate should not live in the ORM at all: put it in Postgres as a row-level security policy, and a Messenger worker, a line of native SQL and an ad-hoc &lt;code&gt;psql&lt;/code&gt; session all get the same &lt;code&gt;WHERE&lt;/code&gt; clause whether anyone remembered it or not. That is correct, and it is the strongest version of the case against doing it in Doctrine. It closes four of the five holes I had just finished listing.&lt;/p&gt;

&lt;p&gt;Then came the warning: their two RLS bugs had both been invisible in tests, because the test connection was a superuser and superusers bypass RLS.&lt;/p&gt;

&lt;p&gt;The trap is wider than superusers, and the wider version is the one that lands on a Symfony deployment. &lt;strong&gt;A plain role that merely owns the table bypasses that table's policies too.&lt;/strong&gt; Not a superuser. No &lt;code&gt;BYPASSRLS&lt;/code&gt;. Just the owner. And the role that owns your tables is, in almost every Symfony deployment I have read, the same role your application connects with.&lt;/p&gt;

&lt;p&gt;Everything below was measured on PostgreSQL 18.3, on a throwaway database, and every command is in the article so you can disagree with the result rather than with me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup, which is the one you would write
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="n"&gt;LOGIN&lt;/span&gt; &lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="s1"&gt;'...'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;app_db&lt;/span&gt; &lt;span class="k"&gt;OWNER&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second line is not a strawman. It is what my own deploy guide says, and it is what makes &lt;code&gt;doctrine:migrations:migrate&lt;/code&gt; work without a privilege dance, which is why it tends to be what a deploy guide says. Then a migration does the usual:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;              &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;total_cents&lt;/span&gt;     &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;tenant_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt;
    &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.organization_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;RLS is on. The policy is right. The role is not a superuser and has no &lt;code&gt;BYPASSRLS&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; current_user | is_superuser | has_bypassrls | table_owner | rls_enabled | rls_forced
--------------+--------------+---------------+-------------+-------------+------------
 app          | f            | f             | app         | t           | f
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two invoices in the table, one for each of two organizations. The application connects as &lt;code&gt;app&lt;/code&gt;, sets nothing, and asks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;invoice&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;2.&lt;/strong&gt; No error, no warning, no log line. Every tenant's rows, through a policy that is enabled and correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  One line changes the answer
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt; &lt;span class="k"&gt;FORCE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same role, same connection, same query. &lt;strong&gt;0.&lt;/strong&gt; Set the tenant and you get exactly the one row you should:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The documentation is not hiding this. PostgreSQL 18, section 5.9, read on 2026-08-21:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Superusers and roles with the &lt;code&gt;BYPASSRLS&lt;/code&gt; attribute always bypass the row security system when accessing a table. Table owners normally bypass row security as well, though a table owner can choose to be subject to row security with &lt;code&gt;ALTER TABLE ... FORCE ROW LEVEL SECURITY&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;"Normally bypass" is doing a lot of work in a sentence most of us read once, while looking for the syntax of &lt;code&gt;CREATE POLICY&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a Symfony project walks into this and a test suite does not catch it
&lt;/h2&gt;

&lt;p&gt;Three things have to line up, and a standard deployment lines up all three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Doctrine creates the tables, under the credentials in &lt;code&gt;DATABASE_URL&lt;/code&gt;.&lt;/strong&gt; There is one connection string in a Symfony app. It runs the migrations and it serves the requests, so the serving role is the owning role. Nothing in the framework, in Doctrine or in Postgres considers that unusual, because it is not unusual. It is the default shape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The failure direction is extra rows, not missing ones.&lt;/strong&gt; A broken filter that returns nothing gets noticed in about four seconds. A filter that returns everything looks like a working page. Under RLS with an owner role you are not in a degraded state, you are in the state you were in before you wrote any of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your fixtures make the two indistinguishable.&lt;/strong&gt; A functional test that seeds one organization, logs a user in and asserts it sees its own row passes identically whether the policy applies or is inert. The assertion that catches this is the negative one: seed a second organization, and assert the first user cannot count it. If your suite has only the positive assertion, the day the policy stops applying is a day nothing turns red.&lt;/p&gt;

&lt;p&gt;Which is how you ship a database with row-level security enabled on every tenant table, policies written and reviewed, and not one of them in force.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two fixes, and I measured both
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;FORCE ROW LEVEL SECURITY&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Add it to the same migration that enables RLS, next to the policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt; &lt;span class="k"&gt;FORCE&lt;/span&gt;  &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cheap, local, no infrastructure request. The catch is that it is per table and there is no default: the next tenant table someone adds in six months comes back with &lt;code&gt;relforcerowsecurity = false&lt;/code&gt; and a policy that does nothing. This is a guarantee that decays.&lt;/p&gt;

&lt;h3&gt;
  
  
  A serving role that does not own anything
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;app_migrate&lt;/span&gt; &lt;span class="n"&gt;LOGIN&lt;/span&gt; &lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="s1"&gt;'...'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- owns the schema, runs migrations&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;app_serve&lt;/span&gt;   &lt;span class="n"&gt;LOGIN&lt;/span&gt; &lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="s1"&gt;'...'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- serves requests, owns nothing&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="n"&gt;TABLES&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;app_serve&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Connecting as &lt;code&gt;app_serve&lt;/code&gt;, with no &lt;code&gt;FORCE&lt;/code&gt; anywhere and nothing set, the same two tables return &lt;strong&gt;0 of 2&lt;/strong&gt;. Set the tenant inside a transaction and you get &lt;strong&gt;1&lt;/strong&gt;. The bypass never existed, because ownership never existed.&lt;/p&gt;

&lt;p&gt;This is the version that does not decay, and it is the version that costs you something real: two connection strings, a migration step that runs as a different user than the app, default privileges to get right for future tables, and a deploy document that now has a paragraph in it. If you are shipping a repo that other people deploy on infrastructure you will never see, that paragraph is a support cost forever. That trade is the actual decision, and it is not a database question.&lt;/p&gt;

&lt;h3&gt;
  
  
  The guard that survives the next table
&lt;/h3&gt;

&lt;p&gt;Whichever you pick, this belongs in your suite, not in a runbook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relrowsecurity&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relforcerowsecurity&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;forced&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relnamespace&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_attribute&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attrelid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;
                   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'organization_id'&lt;/span&gt;
                   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attisdropped&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'r'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every table with an &lt;code&gt;organization_id&lt;/code&gt; column, and whether its policies actually apply. Assert that the list of unprotected ones is empty and the test names the new table for you the day someone adds it. On my demo database it correctly reported one table protected and one not, which is the only reason I trust the rest of this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  And once RLS does apply, the setting has two ways to be missing
&lt;/h2&gt;

&lt;p&gt;The policy reads &lt;code&gt;current_setting('app.organization_id', true)&lt;/code&gt;. There are two different empty states behind that call, they fail differently, and only one of them is loud.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SET LOCAL&lt;/code&gt; outside a transaction block does nothing. Postgres raises a &lt;strong&gt;warning&lt;/strong&gt;, not an error, and PDO does not turn warnings into exceptions:&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;$pdo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"SET LOCAL app.organization_id = '2'"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// no exception&lt;/span&gt;
&lt;span class="nv"&gt;$pdo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"SELECT current_setting('app.organization_id', true)"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fetchColumn&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// string(0) ""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Symfony request has no open transaction until something flushes, so a &lt;code&gt;kernel.request&lt;/code&gt; listener that issues &lt;code&gt;SET LOCAL&lt;/code&gt; is issuing it into nothing. Scoping a request with RLS means an explicit transaction wrapped around the whole request, which is a much bigger architectural commitment than the two lines it looks like.&lt;/p&gt;

&lt;p&gt;Then the two empty states diverge, and this is worth knowing before it happens in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;never set&lt;/strong&gt;: &lt;code&gt;current_setting(..., true)&lt;/code&gt; returns &lt;code&gt;NULL&lt;/code&gt;, &lt;code&gt;NULL::int&lt;/code&gt; is &lt;code&gt;NULL&lt;/code&gt;, the comparison is &lt;code&gt;NULL&lt;/code&gt;, you get &lt;strong&gt;zero rows and no error&lt;/strong&gt;. A blank dashboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;set, then discarded&lt;/strong&gt;: the setting exists and is the empty string, and &lt;code&gt;''::int&lt;/code&gt; throws &lt;code&gt;SQLSTATE[22P02] invalid input syntax for type integer: ""&lt;/code&gt;. A 500 on every query against a tenant table.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same missing tenant, one silent and one fatal, decided by whether that variable was ever touched on the connection. Write the policy so you choose which one you get, rather than finding out.&lt;/p&gt;

&lt;p&gt;And the reason it has to be &lt;code&gt;SET LOCAL&lt;/code&gt; rather than &lt;code&gt;SET&lt;/code&gt;: a plain &lt;code&gt;SET&lt;/code&gt; outlives the transaction. Measured on one connection, two consecutive transactions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-- request A, plain SET, tenant 2
   rows: 1
-- request B on the same connection, sets nothing
   current_setting: '2'
   rows it can see: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Request B never identified itself and is reading tenant 2. With persistent connections or a pooler in transaction mode, request B is a different customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would keep
&lt;/h2&gt;

&lt;p&gt;RLS is the stronger mechanism. The comment that started this was right that it closes the holes an ORM-level filter leaves open, and I said so at the time. What I would not do is adopt it on the strength of &lt;code&gt;ENABLE ROW LEVEL SECURITY&lt;/code&gt; and a policy that reviews well, because that pair is exactly the configuration I measured returning every row in the table.&lt;/p&gt;

&lt;p&gt;Three things, if you are reaching for it this week. Check &lt;code&gt;relforcerowsecurity&lt;/code&gt;, not &lt;code&gt;relrowsecurity&lt;/code&gt;, because the first is the one that means anything when your app owns its tables. Decide between &lt;code&gt;FORCE&lt;/code&gt; and a non-owning serving role on deployment cost, not on elegance, since both were airtight when measured. And test the negative case, because the positive one passes with the policy switched off.&lt;/p&gt;

&lt;p&gt;I maintain &lt;a href="https://shipanvil.com" rel="noopener noreferrer"&gt;ShipAnvil&lt;/a&gt;, a Symfony 7.4 LTS SaaS starter. Its tenancy layer is the Doctrine filter from the previous article, not this, and the paragraph about deployment cost is why. The article stands on its own. If your production database has RLS enabled today, the &lt;code&gt;pg_class&lt;/code&gt; query above takes ten seconds and I would run it before finishing this page.&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>php</category>
      <category>postgres</category>
      <category>database</category>
    </item>
    <item>
      <title>Single-database multi-tenancy in Symfony: a 31-line Doctrine filter, and the five places it never runs</title>
      <dc:creator>Eric Mollenthiel</dc:creator>
      <pubDate>Wed, 19 Aug 2026 15:51:43 +0000</pubDate>
      <link>https://dev.to/mollenthiel/single-database-multi-tenancy-in-symfony-a-31-line-doctrine-filter-and-the-five-places-it-never-18pg</link>
      <guid>https://dev.to/mollenthiel/single-database-multi-tenancy-in-symfony-a-31-line-doctrine-filter-and-the-five-places-it-never-18pg</guid>
      <description>&lt;p&gt;Single-database multi-tenancy is the cheapest kind: one schema, one connection, an &lt;code&gt;organization_id&lt;/code&gt; column on every tenant-owned table. The whole design rests on one promise, and it is a promise about &lt;em&gt;forgetting&lt;/em&gt;: no developer on the team will ever have to remember to write &lt;code&gt;WHERE organization_id = ?&lt;/code&gt;, because forgetting it once leaks another customer's data.&lt;/p&gt;

&lt;p&gt;Doctrine has had the tool for this for years. It is a &lt;code&gt;SQLFilter&lt;/code&gt;, it is about thirty lines, and almost every article about it stops at the happy path. The interesting part is not the filter. It is the map of the places where it is simply not there, because that map is what you actually have to defend.&lt;/p&gt;

&lt;p&gt;Everything below is read from Doctrine ORM 3.6.7 and from a suite that runs on every commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The filter
&lt;/h2&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;OrganizationFilter&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;SQLFilter&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;string&lt;/span&gt; &lt;span class="no"&gt;NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'organization'&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;string&lt;/span&gt; &lt;span class="no"&gt;PARAMETER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'organization_id'&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;addFilterConstraint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ClassMetadata&lt;/span&gt; &lt;span class="nv"&gt;$targetEntity&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;$targetTableAlias&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;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;$targetEntity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getReflectionClass&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;implementsInterface&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrganizationOwnedInterface&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&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;return&lt;/span&gt; &lt;span class="nf"&gt;\sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'%s.organization_id = %s'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$targetTableAlias&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;getParameter&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;PARAMETER&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;OrganizationOwnedInterface&lt;/code&gt; is a marker with one method, &lt;code&gt;getOrganization()&lt;/code&gt;. An entity opts into tenancy by implementing it, and that is the entire public API of the mechanism. No attribute to remember, no base class to extend, no trait whose absence is invisible in a diff.&lt;/p&gt;

&lt;p&gt;The filter is declared in &lt;code&gt;doctrine.yaml&lt;/code&gt; with &lt;code&gt;enabled: false&lt;/code&gt;. That is deliberate, and it is the first design decision worth arguing about: a filter that is on by default in the container is on in your fixtures, in your migrations, in your data-repair scripts, and it will bite you at three in the morning. It gets turned on by the layer that knows who is asking.&lt;/p&gt;

&lt;h2&gt;
  
  
  The layer that knows who is asking
&lt;/h2&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;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getSubscribedEvents&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Right after the firewall (priority 8) so the user is available.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;KernelEvents&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;REQUEST&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;'onKernelRequest'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&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;onKernelRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;RequestEvent&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="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;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isMainRequest&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str_starts_with&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;getPathInfo&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'/admin'&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;$organization&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;organizationContext&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getOrganization&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;$organization&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;$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="nf"&gt;getFilters&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;enable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrganizationFilter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;NAME&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;setParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrganizationFilter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;PARAMETER&lt;/span&gt;&lt;span class="p"&gt;,&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;$organization&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getId&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Priority 7 is not a magic number. Symfony's &lt;code&gt;Firewall&lt;/code&gt; listener subscribes to &lt;code&gt;kernel.request&lt;/code&gt; at priority 8, so 7 is the first slot where &lt;code&gt;Security::getUser()&lt;/code&gt; is populated. Higher and you get no user and therefore no filter at all, which is the worst possible failure mode, because the page still renders.&lt;/p&gt;

&lt;p&gt;That is the whole mechanism. Now the useful part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Place 1: the console, and every Messenger worker
&lt;/h2&gt;

&lt;p&gt;There is no &lt;code&gt;kernel.request&lt;/code&gt; in a CLI process. Your commands, your cron jobs and your Messenger consumers therefore run with the filter &lt;strong&gt;off&lt;/strong&gt;, seeing every tenant's rows.&lt;/p&gt;

&lt;p&gt;This is correct behaviour and you want it: a nightly billing command has to iterate over all organizations. But it means the guarantee "tenant data is invisible by default" is a guarantee about HTTP, not about your application. Every command that touches tenant-owned entities has to scope itself explicitly, and there is no compiler to remind you.&lt;/p&gt;

&lt;p&gt;The honest framing is: the filter is a safety net under your controllers. Under your workers there is no net, and pretending otherwise is how a support script mails the wrong invoice to the wrong customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Place 2: your own back office, on purpose
&lt;/h2&gt;

&lt;p&gt;An admin panel exists precisely to look across tenants. Filtering it would make the metrics wrong and the CRUD useless, so &lt;code&gt;/admin&lt;/code&gt; is exempted by path.&lt;/p&gt;

&lt;p&gt;The trade is that a path prefix now carries a security consequence, and path prefixes are easy to change. It only holds because the same prefix is locked to &lt;code&gt;ROLE_ADMIN&lt;/code&gt; in &lt;code&gt;access_control&lt;/code&gt;. If you copy this pattern, copy both halves, and treat the exemption list as security-critical code rather than as configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Place 3: &lt;code&gt;find()&lt;/code&gt; when the entity is already in the identity map
&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;// EntityManager::find(), doctrine/orm 3.6.7&lt;/span&gt;
&lt;span class="nv"&gt;$entity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$unitOfWork&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tryGetById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sortedId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$class&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;rootEntityName&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;find()&lt;/code&gt; returns from the identity map before any SQL is generated. If an entity belonging to another organization was loaded earlier in the same request, by a fixture, by a cascade, by a &lt;code&gt;getReference()&lt;/code&gt; that got initialized, then &lt;code&gt;find()&lt;/code&gt; hands it back and no filter is consulted, because no query happens.&lt;/p&gt;

&lt;p&gt;The same applies to &lt;code&gt;getReference()&lt;/code&gt; itself, which builds a proxy from an id without touching the database at all. Passing a user-supplied id to &lt;code&gt;getReference()&lt;/code&gt; and trusting the filter to reject it does nothing: the proxy is returned, and it only fails much later, when something initializes it.&lt;/p&gt;

&lt;p&gt;Practical rule: a filter protects &lt;strong&gt;queries&lt;/strong&gt;, not &lt;strong&gt;object identity&lt;/strong&gt;. Authorization on an id that came from the outside is still authorization. Keep your voters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Place 4: DBAL, and every line of native SQL
&lt;/h2&gt;

&lt;p&gt;Filters are a DQL concern. &lt;code&gt;$connection-&amp;gt;executeQuery()&lt;/code&gt; and &lt;code&gt;createNativeQuery()&lt;/code&gt; never see them. This is obvious once stated, and it is where the leaks actually happen, because native SQL is exactly what people reach for on the reporting and export screens, which are exactly the screens that show a lot of rows at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Place 5: joined inheritance, when the column is not on the root table
&lt;/h2&gt;

&lt;p&gt;This one is silent, and it is the reason to read the ORM source rather than the documentation.&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;// SqlWalker::generateFilterConditionSQL(), doctrine/orm 3.6.7&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;ClassMetadata&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;INHERITANCE_TYPE_JOINED&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;// The classes in the inheritance will be added to the query one by one,&lt;/span&gt;
    &lt;span class="c1"&gt;// but only the root node is getting filtered&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;$targetEntity&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="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$targetEntity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;rootEntityName&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="s1"&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;With &lt;code&gt;JOINED&lt;/code&gt; inheritance, Doctrine only ever offers the &lt;strong&gt;root&lt;/strong&gt; entity to your filter. So if your abstract root does not implement the tenancy interface and each concrete subclass does, the subclass is skipped by Doctrine and the root is skipped by your own &lt;code&gt;implementsInterface()&lt;/code&gt; check. Two correct-looking guards, and the result is no &lt;code&gt;WHERE&lt;/code&gt; clause at all.&lt;/p&gt;

&lt;p&gt;The fix is a modelling rule, not a code change: in a joined hierarchy, &lt;code&gt;organization_id&lt;/code&gt; and the marker interface belong to the &lt;strong&gt;root&lt;/strong&gt; entity. Worth an architecture test if your domain uses inheritance.&lt;/p&gt;

&lt;h2&gt;
  
  
  And one place it does run, where most people assume it does not
&lt;/h2&gt;

&lt;p&gt;The folklore says Doctrine filters only apply to &lt;code&gt;SELECT&lt;/code&gt;. That is false in ORM 3, and it is easy to check: &lt;code&gt;walkUpdateStatement()&lt;/code&gt; and &lt;code&gt;walkDeleteStatement()&lt;/code&gt; both call &lt;code&gt;walkWhereClause()&lt;/code&gt;, which is exactly where filters are injected. Here is the SQL Doctrine actually generated for me, with the filter enabled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- DQL: DELETE FROM SampleNote n&lt;/span&gt;
&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sample_notes&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sample_notes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'...'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;-- DQL: UPDATE SampleNote n SET n.title = :t&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;sample_notes&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sample_notes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'...'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bulk DQL statements are scoped. Note the table name in place of an alias, since &lt;code&gt;useSqlTableAliases&lt;/code&gt; is false for those statements. Good news, but do not let it lull you: this is the ORM's DQL path only, and place 4 still stands one line away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving it, rather than believing it
&lt;/h2&gt;

&lt;p&gt;An isolation guarantee that is not tested is a comment. The check is short, and the part that matters is the assertion on the filter itself, the one that fails loudly the day someone "simplifies" the subscriber:&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;$client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;loginUser&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="nv"&gt;$client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'GET'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/dashboard'&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="nf"&gt;assertTrue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getFilters&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;isEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrganizationFilter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;NAME&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="nv"&gt;$notes&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;getRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SampleNote&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;findAll&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="nf"&gt;assertCount&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="nv"&gt;$notes&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="nf"&gt;assertSame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Mine'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$notes&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getTitle&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus its mirror image, that an anonymous request leaves the filter &lt;strong&gt;off&lt;/strong&gt;, which is what catches a firewall-priority regression before your customers do.&lt;/p&gt;

&lt;p&gt;Two organizations, one row each, one authenticated request. It runs in under a second, and it is the only reason anyone should believe the paragraph at the top of this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would keep
&lt;/h2&gt;

&lt;p&gt;Three sentences, if you are building this today.&lt;/p&gt;

&lt;p&gt;The filter belongs off in the container and on at the edge, because the layer that knows the tenant is the only layer entitled to turn it on. The marker interface belongs on the root entity, and an architecture test should say so. And the filter is a net under HTTP only, so every command, every consumer and every line of native SQL is code you have to read with tenancy in mind.&lt;/p&gt;

&lt;p&gt;I maintain &lt;a href="https://shipanvil.com" rel="noopener noreferrer"&gt;ShipAnvil&lt;/a&gt;, a Symfony 7.4 LTS SaaS starter, and this is the tenancy layer it ships, isolation tests included. The article stands on its own, though. If you find a sixth hole, I would genuinely like to hear about it.&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>php</category>
      <category>doctrine</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Every no-code builder generates code. I shipped an interpreter instead.</title>
      <dc:creator>Eric Mollenthiel</dc:creator>
      <pubDate>Sat, 15 Aug 2026 13:00:20 +0000</pubDate>
      <link>https://dev.to/mollenthiel/every-no-code-builder-generates-code-i-shipped-an-interpreter-instead-3bhd</link>
      <guid>https://dev.to/mollenthiel/every-no-code-builder-generates-code-i-shipped-an-interpreter-instead-3bhd</guid>
      <description>&lt;p&gt;Every no-code builder I have used ends the same way: you drag boxes around, you press&lt;br&gt;
Save, and somewhere a template engine writes source code you are now responsible for.&lt;/p&gt;

&lt;p&gt;I spent a few months building a plugin builder for Minecraft servers, and the obvious&lt;br&gt;
version of it generates Java. Blocks in, &lt;code&gt;.java&lt;/code&gt; out, Gradle, a jar you download. I got&lt;br&gt;
close enough to that design to see where it ends, then threw it away and wrote an&lt;br&gt;
interpreter instead.&lt;/p&gt;

&lt;p&gt;This is what that decision actually costs and actually buys, with the parts that only&lt;br&gt;
became obvious after the engine existed.&lt;/p&gt;
&lt;h2&gt;
  
  
  The domain, in four lines
&lt;/h2&gt;

&lt;p&gt;A Minecraft server plugin is a jar built against the Paper API. It hooks events (a player&lt;br&gt;
joins, a block breaks, someone types in chat) and reacts.&lt;/p&gt;

&lt;p&gt;Twice a year Minecraft ships a version, the API moves, and every plugin on every server&lt;br&gt;
breaks at once. Plugin authors spend their lives recompiling. Server owners spend theirs&lt;br&gt;
waiting. Fifteen years of this, and it is the single loudest complaint in the ecosystem.&lt;/p&gt;

&lt;p&gt;That release cycle is not a detail of my domain. It is the thing that decides the&lt;br&gt;
architecture.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why generating Java is the trap
&lt;/h2&gt;

&lt;p&gt;Generated code looks free at the design stage. You already know the shape of the output,&lt;br&gt;
templates are easy, and the result is a normal plugin that behaves like every other plugin&lt;br&gt;
on the server. The build is a solved problem.&lt;/p&gt;

&lt;p&gt;Then you write down what happens on the day the API moves.&lt;/p&gt;

&lt;p&gt;Every jar you have ever generated is frozen. The generator can be fixed in an afternoon,&lt;br&gt;
but the fix reaches nobody: the broken code is already sitting in other people's server&lt;br&gt;
directories. To ship it you have to regenerate and recompile every plugin of every user,&lt;br&gt;
which means you need the original inputs of all of them, a build farm, a queue, and a&lt;br&gt;
migration story for the ones whose regenerated output no longer compiles. A bug in a&lt;br&gt;
template becomes an outage with a per-customer fan-out.&lt;/p&gt;

&lt;p&gt;And that is the good day. On the bad day you are compiling and executing generated code in&lt;br&gt;
production, which means a JDK, Gradle, a worker pool and a cache in the serving path, and&lt;br&gt;
an attack surface shaped like "arbitrary Java, from the internet, run on a game server".&lt;br&gt;
Minecraft in particular had a real supply chain attack in 2023 (fractureiser), spread&lt;br&gt;
through plugin jars. Becoming another distribution channel for opaque bytecode was not a&lt;br&gt;
neutral choice.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I shipped instead
&lt;/h2&gt;

&lt;p&gt;The studio produces a versioned JSON spec: trigger, conditions, actions. One hand-written&lt;br&gt;
Paper plugin, the runtime, reads specs and executes them. Nothing generates Java anywhere.&lt;/p&gt;

&lt;p&gt;A complete plugin:&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;"specVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;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;"Welcome"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rules"&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;"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;"Welcome message"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"trigger"&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;"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;"player.join"&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;"conditions"&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;"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;"player.has_permission"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"params"&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;"permission"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"nimblock.vip"&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;span class="nl"&gt;"actions"&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;"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;"player.send_message"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"params"&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;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;amp;6Welcome {player}!"&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;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;Saving that writes a row. Installing it drops a file in the server volume and hot reloads.&lt;br&gt;
No build step exists to be slow, so nothing has to be made fast.&lt;/p&gt;

&lt;p&gt;The comparison that decided it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Generate and compile&lt;/th&gt;
&lt;th&gt;Interpret a spec&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Save a plugin&lt;/td&gt;
&lt;td&gt;a Gradle build, tens of seconds&lt;/td&gt;
&lt;td&gt;a database write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Install on a running server&lt;/td&gt;
&lt;td&gt;rebuild, redeploy, restart&lt;/td&gt;
&lt;td&gt;drop the JSON, hot reload&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Production infrastructure&lt;/td&gt;
&lt;td&gt;JDK, Gradle, cache, workers, queue&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Attack surface&lt;/td&gt;
&lt;td&gt;compiling and running generated code&lt;/td&gt;
&lt;td&gt;closed grammar, no arbitrary Java&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fix an engine bug&lt;/td&gt;
&lt;td&gt;recompile every plugin of every user&lt;/td&gt;
&lt;td&gt;publish one jar&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Minecraft ships a version&lt;/td&gt;
&lt;td&gt;every generated jar is broken&lt;/td&gt;
&lt;td&gt;the runtime absorbs it once&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is the whole product. When the API moves, I fix one plugin, everyone's rules&lt;br&gt;
keep running, and a spec written today should still run in three years untouched.&lt;/p&gt;
&lt;h2&gt;
  
  
  The part I did not see coming: the grammar becomes data
&lt;/h2&gt;

&lt;p&gt;Here is the second-order effect, and it turned out to matter more than the build times.&lt;/p&gt;

&lt;p&gt;When the grammar is a data file instead of a code generator, it can be the single source of&lt;br&gt;
truth for &lt;em&gt;both&lt;/em&gt; sides of the system. Mine is one &lt;code&gt;catalogue-1.json&lt;/code&gt;: 9 triggers, 8&lt;br&gt;
conditions, 14 actions. The JSON Schema is derived from it. The PHP validator in the studio&lt;br&gt;
reads it. The Java engine reads the same file, copied into the jar at build time.&lt;/p&gt;

&lt;p&gt;That buys two things a code generator cannot have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing can be offered and not implemented.&lt;/strong&gt; The worst possible bug in a builder like&lt;br&gt;
this is silent: an action the editor accepts, that nothing executes. The user places it,&lt;br&gt;
the panel says fine, and nothing happens in game with no message anywhere. So the runtime&lt;br&gt;
has a test that compares the catalogue to the handler registry in both directions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;everyActionHasAHandler&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;assertEquals&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;TreeSet&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;catalogue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;actionIds&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;TreeSet&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Actions&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="na"&gt;ids&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing in the catalogue without a handler, nothing handled outside the catalogue. With&lt;br&gt;
generated code that test does not exist, because there is no registry to compare against:&lt;br&gt;
you would be diffing templates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The catalogue can carry a type system.&lt;/strong&gt; Each trigger declares what it &lt;em&gt;provides&lt;/em&gt;, each&lt;br&gt;
action declares what it &lt;em&gt;requires&lt;/em&gt;:&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"schedule.repeat"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"context"&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"player.heal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="nl"&gt;"requires"&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="s2"&gt;"player"&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cancel_event"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nl"&gt;"requires"&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="s2"&gt;"cancellable"&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;So "heal the player" under "every N seconds" is refused as you write it, not at runtime,&lt;br&gt;
because that trigger provides no player. &lt;code&gt;cancel_event&lt;/code&gt; only exists under a cancellable&lt;br&gt;
trigger. A typo'd &lt;code&gt;{palyer}&lt;/code&gt; is rejected instead of landing in someone's chat as literal&lt;br&gt;
text. That is roughly a type checker, expressed in a data file, and it exists only because&lt;br&gt;
the grammar is data rather than a template.&lt;/p&gt;

&lt;p&gt;Third rule, less about types and more about failure modes: &lt;strong&gt;a spec is accepted or rejected&lt;br&gt;
as a whole&lt;/strong&gt;. A protection plugin that loaded half its rules would fail silently in game,&lt;br&gt;
in the worst way, so it does not load half.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bill
&lt;/h2&gt;

&lt;p&gt;An interpreter is not free, and the price is paid by the user, not by me.&lt;/p&gt;

&lt;p&gt;The grammar is closed. Whatever is not in those 9 triggers, 8 conditions and 14 actions is&lt;br&gt;
impossible, not "on the roadmap". Conditions do not nest either: &lt;code&gt;all&lt;/code&gt; / &lt;code&gt;any&lt;/code&gt; plus a &lt;code&gt;not&lt;/code&gt;&lt;br&gt;
per condition, no boolean tree. That last one is a genuine v1 limitation rather than a&lt;br&gt;
principled stand, and it has an ugly reason: nested booleans draw badly as blocks.&lt;/p&gt;

&lt;p&gt;If you need a guild system with its own database and its own GUI, this is the wrong tool&lt;br&gt;
and it always will be. If you need "when a player joins for the first time, give them a kit&lt;br&gt;
and announce it", it is two minutes. Knowing which side of that line you are on before you&lt;br&gt;
invest is worth more than a longer feature list, so the full grammar is a public page and&lt;br&gt;
you do not need an account to read it.&lt;/p&gt;

&lt;p&gt;The escape hatch is a single "run as console" action, with exactly the power of the server&lt;br&gt;
console and no more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things the interpreter forced me to get right
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Command registration is not hot.&lt;/strong&gt; Paper only accepts new command names during its&lt;br&gt;
&lt;code&gt;COMMANDS&lt;/code&gt; lifecycle event, so a rule that declares a new command exists only after a&lt;br&gt;
restart, while everything else in the same spec is live immediately. Rather than guess, the&lt;br&gt;
agent records what Paper actually registered at boot; the difference with what the&lt;br&gt;
installed specs declare &lt;em&gt;is&lt;/em&gt; the pending list the panel shows. Same field left null tells&lt;br&gt;
me a server predates the studio, so a reload would only produce a baffling "unknown&lt;br&gt;
command" and the panel asks for a restart instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cancellation is applied before the rule's actions, not after.&lt;/strong&gt; For an ordinary rule the&lt;br&gt;
order is unobservable. But chat arrives on another thread, and the remaining actions have&lt;br&gt;
to hop back to the main thread, which is far too late to cancel anything. Applying it first&lt;br&gt;
is the only version where chat moderation rules work at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A failing action stops its rule, never the server.&lt;/strong&gt; Unknown item name, deleted world, a&lt;br&gt;
numeric comparison on text: those are spec mistakes. They are worth one line in the console&lt;br&gt;
and nothing more.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious objection
&lt;/h2&gt;

&lt;p&gt;"Fine, but my server is not hosted by you."&lt;/p&gt;

&lt;p&gt;Right, and this is the part where the design pays off again rather than the part where it&lt;br&gt;
falls apart: an export does not need a JDK either. The runtime jar is already compiled, so&lt;br&gt;
exporting is injecting the specs as resources, rewriting &lt;code&gt;plugin.yml&lt;/code&gt;, and rezipping.&lt;br&gt;
&lt;code&gt;ZipArchive&lt;/code&gt; in PHP, on the order of a hundred milliseconds, no build server anywhere. One&lt;br&gt;
jar per account containing all of its specs, never one jar per plugin, so installing two of&lt;br&gt;
them on the same external server cannot collide.&lt;/p&gt;

&lt;p&gt;To be straight with you: that is designed and costed, not shipped. It is the next batch. I&lt;br&gt;
am saying it here because it is the standard objection to a hosted builder, and because&lt;br&gt;
"the escape route is cheap" is a consequence of the interpreter, not a promise bolted on&lt;br&gt;
afterwards.&lt;/p&gt;

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

&lt;p&gt;It is live and free. The stack is Symfony 8 and PostgreSQL 18 on a single box, Paper&lt;br&gt;
servers in rootless Podman containers, one network per server, filtered egress. The machine&lt;br&gt;
holds twelve 1 GB servers, so there are twelve slots, which is a hardware limit rather than&lt;br&gt;
a growth-hacking number.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The full grammar, every trigger, condition and action, generated from the same file the
engine reads: &lt;a href="https://nimblock.com/en/grammar" rel="noopener noreferrer"&gt;https://nimblock.com/en/grammar&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The studio, with a hosted Paper 26.1.2 server to try it on: &lt;a href="https://nimblock.com/en" rel="noopener noreferrer"&gt;https://nimblock.com/en&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I want back is not signups. It is the list of things you tried to express and could&lt;br&gt;
not, because that list is what decides whether the closed grammar was the right call or&lt;br&gt;
just the easy one.&lt;/p&gt;

</description>
      <category>java</category>
      <category>architecture</category>
      <category>php</category>
      <category>minecraft</category>
    </item>
    <item>
      <title>Your storage format is the one thing you cannot refactor</title>
      <dc:creator>Eric Mollenthiel</dc:creator>
      <pubDate>Sat, 15 Aug 2026 09:52:30 +0000</pubDate>
      <link>https://dev.to/mollenthiel/your-storage-format-is-the-one-thing-you-cannot-refactor-28ng</link>
      <guid>https://dev.to/mollenthiel/your-storage-format-is-the-one-thing-you-cannot-refactor-28ng</guid>
      <description>&lt;p&gt;Ten days ago I published a post about encrypting personal data at rest in Symfony. A reader, Mads Hansen, left a comment that did not argue with the cryptography at all. It argued with seven characters that were not there.&lt;/p&gt;

&lt;p&gt;The type I had shipped told encrypted rows from legacy plaintext rows by looking at the first byte: a &lt;code&gt;{&lt;/code&gt; or a &lt;code&gt;[&lt;/code&gt; meant "this row predates encryption, hand it back as JSON". His objection, compressed: that makes the storage format ambiguous forever, and the ambiguity is a security property, not a cosmetic one, because a row that merely &lt;em&gt;looks&lt;/em&gt; like JSON opts out of decryption no matter who wrote it.&lt;/p&gt;

&lt;p&gt;He was right, and fixing it cost about eighty lines including tests. But the reason I am writing a second post rather than editing the first one is that the bug was not really about encryption. It was about a rule I had followed everywhere else in that codebase and broken here, and it is the one rule of software design I would keep if I had to throw away all the others:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;everything you write can be refactored later, except the format of the bytes you have already stored.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code is soft, storage is not
&lt;/h2&gt;

&lt;p&gt;We say "software" and mean it. A class name is a rename away from being better. A bad abstraction survives until someone has an afternoon. Even a database schema, which feels heavy, is genuinely malleable: a column is added, backfilled, and dropped, and the tooling for that has been boring and reliable for twenty years.&lt;/p&gt;

&lt;p&gt;Formats are different in kind, not in degree, and it took me an embarrassing number of years to feel the difference in my hands rather than know it as a maxim. A format is not code you own. It is an agreement between the process that wrote a value and every process that will ever read it, including the ones running a version of your code that no longer exists, on rows written by a colleague who left, in a database restored from a backup taken before the decision you are about to make.&lt;/p&gt;

&lt;p&gt;You cannot change one side of an agreement unilaterally. What you can do is read the old shape and write the new one, which is exactly what a migration is, and which is exactly what requires you to be able to &lt;em&gt;tell the two apart&lt;/em&gt;. That last part is the whole game.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sniffing is the tax you pay for skipping the version
&lt;/h2&gt;

&lt;p&gt;When a stored value carries no marker, the only way to know what it is, is to look at it and guess. Every codebase has these, and once you learn to see them they are everywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;if ($v[0] === '{')&lt;/code&gt; then it is JSON, otherwise it is PHP &lt;code&gt;serialize()&lt;/code&gt; output.&lt;/li&gt;
&lt;li&gt;if &lt;code&gt;unserialize()&lt;/code&gt; throws, fall back to &lt;code&gt;json_decode()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;if the first two bytes are &lt;code&gt;0x1f 0x8b&lt;/code&gt;, it is gzipped, otherwise it is raw.&lt;/li&gt;
&lt;li&gt;if the string is 32 hexadecimal characters, it is the old token format.&lt;/li&gt;
&lt;li&gt;if the value is base64-decodable, it is the encrypted one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these works. That is precisely the problem: they work well enough that nobody replaces them, and each one quietly promotes an accident of the payload into part of the contract. The last example is the one I shipped, and it is the worst of the family, because "looks like plaintext" and "is plaintext" were being treated as the same statement on a column that exists to keep buyers' names and addresses from being readable.&lt;/p&gt;

&lt;p&gt;There is also a subtler cost, the one that bites long after. A heuristic is not just a guess, it is a &lt;em&gt;constraint on all future values&lt;/em&gt;. Once "starts with &lt;code&gt;{&lt;/code&gt;" means legacy, no future version of the format may ever start with &lt;code&gt;{&lt;/code&gt;. You have spent a byte of design space without noticing, and you will notice the day you want to store something that starts with &lt;code&gt;{&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The window closes when the first row is written
&lt;/h2&gt;

&lt;p&gt;Here is the asymmetry that makes this worth a post.&lt;/p&gt;

&lt;p&gt;Adding a version marker to an empty column costs seven bytes and about four minutes. Adding one to a column with a million rows costs a migration that must read values written without a marker, which means the migration needs a heuristic, which is the very thing you were trying to get rid of. You do not escape the guess, you only get to bury it in a one-off script instead of the read path, and you get to keep it in the read path anyway until the backfill is provably complete.&lt;/p&gt;

&lt;p&gt;So the marker is free exactly once, and the free moment is the moment the column is empty. Which is also the moment when you are most certain you will never need it, because there is only one format and it is obviously the right one. Every ambiguous storage format I have ever met was created by a competent person on the day it was cheapest to prevent.&lt;/p&gt;

&lt;p&gt;My table had five rows. That is the only reason this post describes a fix rather than a permanent scar. If it had held a million, the honest advice would have been the opposite of what follows: keep the heuristic, document it as load-bearing, and write the version prefix only into new values, carrying both paths for years.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the fix looks like
&lt;/h2&gt;

&lt;p&gt;The value stored is now an envelope, and the envelope is three fields that each earn their place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;enc:v1:k1:&amp;lt;base64 nonce + ciphertext&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;enc:v1:&lt;/code&gt; is the format version. &lt;code&gt;k1&lt;/code&gt; is the key identifier. The payload is the payload. Written out as PHP, the read path stops asking the value what it is:&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;convertToPHPValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;AbstractPlatform&lt;/span&gt; &lt;span class="nv"&gt;$platform&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="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="nf"&gt;\is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$value&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;null&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="nc"&gt;CipherEnvelope&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;isEnveloped&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Fail closed: a readable row is a bug or an intrusion, never a fallback.&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;\RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'This value is not in the "'&lt;/span&gt;&lt;span class="mf"&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;PREFIX&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'" envelope.'&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="nv"&gt;$keyId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$ciphertext&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CipherEnvelope&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&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;decode&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="nf"&gt;keyring&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="nv"&gt;$keyId&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;decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ciphertext&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 consequences follow, and only the first one is obvious.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reading fails closed
&lt;/h3&gt;

&lt;p&gt;There is no longer a branch that returns plaintext. A row that does not carry the envelope stops the application instead of quietly serving personal data in clear.&lt;/p&gt;

&lt;p&gt;That is a real cost, paid at the worst moment: restore a pre-migration backup and the app breaks rather than degrades. I want that trade on this column, because the failure mode it replaces is silent. As a general rule for anything with a security dimension: &lt;strong&gt;when a read path has two branches and one of them hands back the unprotected version, the branch is the vulnerability&lt;/strong&gt;, no matter how carefully the condition is written.&lt;/p&gt;

&lt;h3&gt;
  
  
  The key identifier makes rotation real
&lt;/h3&gt;

&lt;p&gt;The previous post admitted, in as many words, that key rotation was not implemented and probably never would be, because rotating means holding two keys at once and the ciphertext carried no way to say which one produced it.&lt;/p&gt;

&lt;p&gt;Once the identifier is in the envelope, rotation stops being a flag day and becomes three ordinary steps: promote a new active key while the old one stays in the keyring as read-only, backfill in the background, then drop the old key. Nothing has to happen atomically, and the day a key is retired too early, the failure is a message naming the missing key identifier instead of an authentication error you have to reverse-engineer.&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="cd"&gt;/** @throws \RuntimeException when a stored value names a key we no longer hold */&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;get&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;$id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;TokenCipher&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;ciphers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$id&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;\RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;\sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'This value was encrypted with key "%s", which is not in the keyring.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$id&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;Note what happened there: a &lt;em&gt;format&lt;/em&gt; decision bought back a &lt;em&gt;capability&lt;/em&gt;. Seven bytes plus an identifier turned "we cannot rotate keys" into "we can rotate keys during business hours". That is the usual shape of this trade, and it is why the version prefix is not speculative generality. It does not anticipate a feature, it preserves the right to have one.&lt;/p&gt;

&lt;p&gt;One detail worth stealing: the identifier that comes out of the database is validated against &lt;code&gt;/^[A-Za-z0-9_-]{1,32}$/&lt;/code&gt; before it is used or logged. It is attacker-influenced data on the day the threat model says the database is the thing you lost, and it ends up in exception messages.&lt;/p&gt;

&lt;h3&gt;
  
  
  The backfill does not guess either
&lt;/h3&gt;

&lt;p&gt;The conversion command reads pre-envelope values by &lt;em&gt;attempting decryption first&lt;/em&gt;, with every key in the keyring, and only treating the value as plain JSON if all of them fail:&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="nc"&gt;EncryptedJsonType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;keyring&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;all&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;$cipher&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="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;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cipher&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$stored&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;\Throwable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Not this key, or not a ciphertext at all: try the next one.&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Nothing decrypted it: it can only be a value from before encryption.&lt;/span&gt;
&lt;span class="k"&gt;try&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;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$stored&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;\Throwable&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;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This ordering is not stylistic. Base64-decoding &lt;code&gt;{"name":"Ada"}&lt;/code&gt; fails outright, so plaintext can never be mistaken for ciphertext, while the reverse test (does it look like JSON?) has no such guarantee. When you must disambiguate, prefer the test that fails for structural reasons over the test that inspects appearances.&lt;/p&gt;

&lt;p&gt;And because the type now refuses anything unenveloped, the same command doubles as the answer to "is the database fully converted?":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bin/console app:encrypt-buyers &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;exits non-zero as long as one row is outside the envelope. That is a cron job and an alert, rather than a paragraph in a runbook that nobody reads after the deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where else the seven bytes apply
&lt;/h2&gt;

&lt;p&gt;Encryption made this vivid, but the column was not special. Anything that outlives the process that wrote it is a format, and deserves a version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Serialized columns.&lt;/strong&gt; A &lt;code&gt;json&lt;/code&gt; column holding a value object is a format the moment the object gains or loses a field. Version it, or you will be reading &lt;code&gt;array_key_exists&lt;/code&gt; archaeology in two years.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache payloads.&lt;/strong&gt; On disk, in Redis, anywhere. The classic failure is a deploy that changes a cached structure and reads yesterday's shape for the ten minutes before the cache turns over. A version in the &lt;em&gt;key&lt;/em&gt; is the cheapest variant: old shape, old key, no reader ever sees it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queue messages.&lt;/strong&gt; During a rolling deploy, two versions of the consumer run at once, by design. The message on the wire is a format shared between two codebases that happen to be the same repository at different commits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public identifiers.&lt;/strong&gt; Anything you print on an invoice, put in a URL, or let a customer paste into a support ticket has escaped your control permanently. A one-character prefix (&lt;code&gt;inv_&lt;/code&gt;, &lt;code&gt;cus_&lt;/code&gt;, the whole Stripe convention) is the same seven bytes wearing a different hat, and it also makes a leaked identifier greppable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exported files.&lt;/strong&gt; A CSV whose column order is the contract, because someone built a spreadsheet on top of it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The test I now apply, and it takes one second: &lt;em&gt;will this value be read by code I have not written yet?&lt;/em&gt; If yes, it needs to say what it is. Not because I expect to change it, but because "I will never need to change this" is a prediction about the future, and the version prefix is the cheapest insurance ever written against being wrong about one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is this not YAGNI?
&lt;/h2&gt;

&lt;p&gt;The objection deserves a straight answer, because "you are not going to need it" is usually right and I use it constantly.&lt;/p&gt;

&lt;p&gt;YAGNI is a rule about &lt;em&gt;code&lt;/em&gt;, and it works because code is deletable. Write the abstraction you do not need, discover it, delete it: the cost of being wrong is bounded and it is paid by one person in one afternoon.&lt;/p&gt;

&lt;p&gt;Formats break the assumption the rule stands on. You cannot delete a format that is already in the database, because you do not own all of its copies: some are in backups, some in a partner's system, some in a CSV a customer downloaded last March. The cost of being wrong is unbounded and it is paid by whoever is on call. When the cost of being wrong is asymmetric by three orders of magnitude, the cheap option is not the smaller diff.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually cost
&lt;/h2&gt;

&lt;p&gt;Eighty lines, tests included, and two hours. A prefix constant, a small class that wraps and splits the envelope, a keyring with one active key and any number of retired ones, a read path that throws instead of falling back, and a backfill command that tries decryption before it tries trust.&lt;/p&gt;

&lt;p&gt;The part I keep thinking about is that none of it came out of a threat model, a checklist, or a review. It came from someone reading a public post carefully enough to notice that a condition was doing more work than it looked like. If you write technical posts and treat the comments as the part that comes after the work, that is backwards: the objection is the work, and it is the only free code review you will ever be offered.&lt;/p&gt;




&lt;p&gt;The code above lives in &lt;a href="https://invoiceanvil.shipanvil.com/" rel="noopener noreferrer"&gt;InvoiceAnvil&lt;/a&gt;, a Shopify app that issues compliant invoices, built on &lt;a href="https://shipanvil.com/" rel="noopener noreferrer"&gt;ShipAnvil&lt;/a&gt;, the Symfony kit that ships the auth, billing, admin and deploy pipeline these apps stand on. The post that started this one is &lt;a href="https://shipanvil.com/blog/encrypt-personal-data-at-rest-symfony-doctrine" rel="noopener noreferrer"&gt;encrypting personal data at rest in Symfony&lt;/a&gt;, and the foundations are in &lt;a href="https://shipanvil.com/blog/deploy-symfony-vps" rel="noopener noreferrer"&gt;deploying Symfony to a production VPS&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://shipanvil.com/blog/storage-format-cannot-refactor" rel="noopener noreferrer"&gt;https://shipanvil.com/blog/storage-format-cannot-refactor&lt;/a&gt;&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>php</category>
      <category>architecture</category>
      <category>security</category>
    </item>
    <item>
      <title>Six decisions behind a shared-expense app, and the one where you must refuse to round</title>
      <dc:creator>Eric Mollenthiel</dc:creator>
      <pubDate>Thu, 13 Aug 2026 13:00:39 +0000</pubDate>
      <link>https://dev.to/mollenthiel/six-decisions-behind-a-shared-expense-app-and-the-one-where-you-must-refuse-to-round-2gd3</link>
      <guid>https://dev.to/mollenthiel/six-decisions-behind-a-shared-expense-app-and-the-one-where-you-must-refuse-to-round-2gd3</guid>
      <description>&lt;p&gt;Splitting one bill is a division. Settling a group of five people over three weeks of shared expenses is a different problem, and it is the one that actually has edge cases.&lt;/p&gt;

&lt;p&gt;I have been building &lt;a href="https://kotisso.com/en" rel="noopener noreferrer"&gt;Kotisso&lt;/a&gt;, a shared expense tracker. The money side of it turned out to be far less about UI than about a handful of decisions that are cheap on day one and expensive to retrofit. This is the list I wish I had started from.&lt;/p&gt;

&lt;p&gt;(One decision is missing here because it already has its own post: who gets the indivisible cent when you split 10 EUR three ways. That is the &lt;a href="https://dev.to/mollenthiel/splitting-eu10-three-ways-the-largest-remainder-method-and-the-tiebreak-everyone-forgets-4aag"&gt;largest remainder method and the tiebreak everyone forgets&lt;/a&gt;. Everything below assumes you got that part right.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The invariant that makes everything testable
&lt;/h2&gt;

&lt;p&gt;Start from one property and refuse to break it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In any group, the sum of all balances is always exactly zero.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A balance is what a person is owed. It is &lt;code&gt;paid - owed + sent - received&lt;/code&gt;: what they advanced, minus what is theirs to bear, plus reimbursements they sent, minus reimbursements they received. Every unit of money entering a group is advanced by exactly one person and borne by one or more people, so the positives and the negatives cancel.&lt;/p&gt;

&lt;p&gt;That single line gives you a free assertion after every write:&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;isConsistent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$balances&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;$sum&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;$balances&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$balance&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$sum&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nv"&gt;$balance&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getBalanceCents&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="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$sum&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;Every accounting bug I hit in the first month showed up as a non-zero sum before it ever showed up on a screen. A share attached to the wrong member, a deleted expense whose shares survived, a settlement counted on one side only: all of them break the invariant, none of them break the layout. That is why you find them with an assertion and not with your eyes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store hundredths even for currencies that have none
&lt;/h2&gt;

&lt;p&gt;Integer cents, never floats. That part is well known. The part that is not:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1500 JPY is stored as &lt;code&gt;150000&lt;/code&gt;.&lt;/strong&gt; The yen has no decimal places, so this looks like pure waste. It is what lets a single integer travel through the splitter, the balance calculator, the settlement planner, the CSV exporter and the PDF renderer without any of them needing to know which currency it carries. Exactly one place in the codebase knows that JPY has zero decimals: the formatter at the very end, and it learns it from ICU rather than from a table I maintain.&lt;/p&gt;

&lt;p&gt;The alternative, letting the scale vary with the currency, means every arithmetic site becomes currency-aware. You will miss one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning balances into transfers
&lt;/h2&gt;

&lt;p&gt;You have balances that sum to zero. You want a small number of bank transfers that zeroes everybody out.&lt;/p&gt;

&lt;p&gt;The exact minimum is NP-hard: it reduces to partitioning debtors into subsets that exactly match creditors. It is also irrelevant at these sizes. What people want is a plan that is short and obviously correct, and greedy gives that with a bound you can explain in one sentence.&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;// Both lists hold positive cents, sorted descending,&lt;/span&gt;
&lt;span class="c1"&gt;// with the member id as tiebreak so the plan is reproducible.&lt;/span&gt;
&lt;span class="nv"&gt;$transfers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="nv"&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="nv"&gt;$j&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;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$debtors&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="nv"&gt;$j&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$creditors&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$debtors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$i&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="nv"&gt;$creditors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$j&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$amount&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="nv"&gt;$transfers&lt;/span&gt;&lt;span class="p"&gt;[]&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;Transfer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$debtors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'member'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$creditors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$j&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'member'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$amount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$debtors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$i&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="o"&gt;-=&lt;/span&gt; &lt;span class="nv"&gt;$amount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$creditors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$j&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="o"&gt;-=&lt;/span&gt; &lt;span class="nv"&gt;$amount&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="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$debtors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$i&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="nv"&gt;$i&lt;/span&gt;&lt;span class="o"&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$creditors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$j&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="nv"&gt;$j&lt;/span&gt;&lt;span class="o"&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;Each iteration settles at least one person completely, so &lt;code&gt;n&lt;/code&gt; members need at most &lt;code&gt;n - 1&lt;/code&gt; transfers. A flatshare of four ends up with three payments instead of the six that "everybody pays everybody back" produces.&lt;/p&gt;

&lt;p&gt;Two details worth keeping. The two &lt;code&gt;if&lt;/code&gt;s at the end are independent, not an &lt;code&gt;if/else&lt;/code&gt;: when a debtor and a creditor match exactly, both are finished, and advancing only one index leaves a zero-cent entry the loop then has to step over. And the descending sort needs a deterministic tiebreak, or two calls on the same data propose two different plans and the user watches the suggestion move around under them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mode where you must refuse rather than fix
&lt;/h2&gt;

&lt;p&gt;Four split modes: equally, by shares, by percentage, and by exact amounts. The first three are proportional. The fourth is a trap.&lt;/p&gt;

&lt;p&gt;In exact-amounts mode the user types each person's share. If those shares do not add up to the expense total, the tempting move is to absorb the difference into the largest one. My code did that for a while. Then I did the arithmetic on a realistic case: a 240 EUR restaurant bill entered as exact amounts that fall 15 EUR short, and one person silently pays 15 EUR more than they agreed to, with nothing on screen saying so.&lt;/p&gt;

&lt;p&gt;Now it returns 422 and names the gap. Whoever does not want to count to the cent has three other modes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A rounding rule is acceptable when nobody chose the numbers. It is not acceptable when somebody did.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Freeze the exchange rate at entry
&lt;/h2&gt;

&lt;p&gt;You are on holiday, you pay 1 200 THB, the group counts in euros. The obvious implementation calls a rate API.&lt;/p&gt;

&lt;p&gt;Do not. A live rate means every balance in the group drifts every morning, so a settled trip un-settles itself and a debt someone already paid comes back at a different number. Store what was entered, the currency, and the rate that was applied, then convert once and never again. The card's real rate is never the ECB's anyway, so the field has to be editable regardless.&lt;/p&gt;

&lt;p&gt;The downstream consequence is the good kind: &lt;code&gt;amountCents&lt;/code&gt; holds the amount in the group's currency and nothing below the entry point knows a conversion ever happened. Balances, the invariant, the settlement plan, the exports, none of them were touched when multi-currency shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pending expense counts nowhere
&lt;/h2&gt;

&lt;p&gt;Groups can require expenses to be approved by the people they concern. An expense that is awaiting approval, or disputed, is excluded &lt;strong&gt;entirely&lt;/strong&gt;: from the balances, from the group total, from the per-category breakdown.&lt;/p&gt;

&lt;p&gt;The tempting alternative is to count it as provisional and mark it visually. That keeps the invariant intact and makes every number on the page mean something slightly different from what it says. Excluding it in one place, at the top of the balance loop, is one &lt;code&gt;continue&lt;/code&gt; and no ambiguity:&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;$group&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getExpenses&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;$expense&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;$expense&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getStatus&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;countsInBalances&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="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  And one thing that is not about money at all
&lt;/h2&gt;

&lt;p&gt;A free plan shows fewer rows, never different numbers. Expenses outside the visible window still count in the balances, in the group total and in the category breakdown, and the export says in the file itself what it summarised rather than dropping it.&lt;/p&gt;

&lt;p&gt;A truncated statement is a false statement, and an accounting tool that produces one is worth nothing. That rule cost more design time than the algorithms did.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order that worked
&lt;/h2&gt;

&lt;p&gt;Invariant first, integer cents second, splitter third, screens last. Each of those is a few hours at the start and a data migration later.&lt;/p&gt;

&lt;p&gt;The long version of the balance calculation, written for people rather than developers, is &lt;a href="https://kotisso.com/en/blog/who-owes-who-how-to-calculate" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>php</category>
      <category>algorithms</category>
      <category>webdev</category>
      <category>symfony</category>
    </item>
    <item>
      <title>A week is seven nights, not 168 hours: dates in a shared-custody calendar</title>
      <dc:creator>Eric Mollenthiel</dc:creator>
      <pubDate>Wed, 12 Aug 2026 16:51:44 +0000</pubDate>
      <link>https://dev.to/mollenthiel/a-week-is-seven-nights-not-168-hours-dates-in-a-shared-custody-calendar-3of4</link>
      <guid>https://dev.to/mollenthiel/a-week-is-seven-nights-not-168-hours-dates-in-a-shared-custody-calendar-3of4</guid>
      <description>&lt;p&gt;Add seven days to a timestamp and you get the same day next week. That holds right up until the last Sunday in October, when it quietly stops holding and nothing tells you.&lt;/p&gt;

&lt;p&gt;I have been building &lt;a href="https://nestido.com" rel="noopener noreferrer"&gt;Nestido&lt;/a&gt;, a planner for separated parents: who has the children, when, and where. The family declares its custody rhythm once (alternating weeks, 2-2-3, 5-2-2-5), the app predicts the calendar from it, and the parents only record the deviations. Symfony 8, PostgreSQL, one server.&lt;/p&gt;

&lt;p&gt;From the outside it is a calendar with colours. From the inside it is date arithmetic with a domain that punishes you twice a year, in a way no user will ever report. Nobody files a bug that says "your daylight saving handling is off". They see a number that looks wrong, they say nothing, and they stop trusting the app.&lt;/p&gt;

&lt;p&gt;Here are the five rules I ended up with. Every one of them exists because the obvious version was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The unit is the night, and a night is not a duration
&lt;/h2&gt;

&lt;p&gt;The first instinct is to store intervals and divide by 24. It is wrong in both directions, and you can prove it without leaving your own timezone.&lt;/p&gt;

&lt;p&gt;In Paris, 1 June 23:30 to 2 June 22:30 is twenty-three hours and it is &lt;strong&gt;one&lt;/strong&gt; night. 1 June 00:30 to 1 June 23:30 is also twenty-three hours and it is &lt;strong&gt;zero&lt;/strong&gt; nights: nobody slept anywhere new. Identical durations, different answers.&lt;/p&gt;

&lt;p&gt;So the count is not a duration at all, it is a number of local midnights crossed:&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;nights&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\DateTimeZone&lt;/span&gt; &lt;span class="nv"&gt;$zone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;int&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$from&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;startsAt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setTimezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$zone&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;setTime&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;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$to&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;endsAt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setTimezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$zone&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;setTime&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;0&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;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$from&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$to&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;days&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;Truncate both ends to local midnight, then diff. &lt;code&gt;days&lt;/code&gt; already accounts for the transitions, so the two cases that break the hour-based version come out right:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;28 March 20:00 to 30 March 08:00 in Paris is &lt;strong&gt;35&lt;/strong&gt; hours (the clocks jump forward). Two nights.&lt;/li&gt;
&lt;li&gt;24 October 20:00 to 26 October 08:00 is &lt;strong&gt;37&lt;/strong&gt; hours (the clocks fall back). Two nights.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Divide by 24 and you get 1 and 1. In spring you have just deleted a night from every family in the country, and the statistics screen, which is the whole reason the app exists, is quietly off by one for the rest of the year.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Do the arithmetic in the family's timezone, store the result in UTC
&lt;/h2&gt;

&lt;p&gt;Everything in my database is UTC. That is the easy half, and it lulls you into doing the &lt;em&gt;arithmetic&lt;/em&gt; in UTC too. That part is wrong.&lt;/p&gt;

&lt;p&gt;A handover happens at 18:00 local. It happens at 18:00 in July and at 18:00 in November. If you add seven days to a UTC instant, you get the same UTC instant one week later, which is a different wall clock time on the other side of a transition:&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;$start&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;DateTimeImmutable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-03-25 18:00'&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;DateTimeZone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Europe/Paris'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="nv"&gt;$start&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setTimezone&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;DateTimeZone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'UTC'&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;modify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'+7 days'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// 1 April, 19:00 local. One hour late, forever.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule that survived contact with the domain: &lt;strong&gt;cycle boundaries are computed on local dates at the local switch time, and converted to UTC only at the very end.&lt;/strong&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="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;boundary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\DateTimeImmutable&lt;/span&gt; &lt;span class="nv"&gt;$day&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;\DateTimeImmutable&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;$day&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;intdiv&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;switchMinutes&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;switchMinutes&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setTimezone&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;\DateTimeZone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'UTC'&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 test that guards it is the clearest statement of the whole idea. Two consecutive weekly boundaries around the October change, stored as UTC instants:&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;self&lt;/span&gt;&lt;span class="o"&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;'2026-10-23 16:00'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$segments&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Y-m-d H:i'&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="nf"&gt;assertSame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-10-30 17:00'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$segments&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Y-m-d H:i'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Different UTC instants, one hour apart. Both are 18:00 in Paris. If your two boundaries have the same UTC time on either side of a transition, your handover has drifted, and it will drift again in March.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Never walk a calendar from midnight. Walk from noon.
&lt;/h2&gt;

&lt;p&gt;To render a month you loop over days. The obvious loop starts at local midnight and adds one day at a time. It works everywhere in Europe and North America, which is exactly why it is dangerous: in some zones midnight does not exist.&lt;/p&gt;

&lt;p&gt;Chile moves its clocks at midnight. So do a handful of others, and Brazil did until 2019. On the transition night, &lt;code&gt;00:00&lt;/code&gt; is not a valid local time and PHP moves you to &lt;code&gt;01:00&lt;/code&gt;. From then on, the loop keeps that hour:&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;$c&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;DateTimeImmutable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-09-04 00:00'&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;DateTimeZone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'America/Santiago'&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="nv"&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="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="o"&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;echo&lt;/span&gt; &lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Y-m-d H:i P'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;modify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'+1 day'&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-09-04 00:00 -04:00
2026-09-05 00:00 -04:00
2026-09-06 01:00 -03:00   &amp;lt;- midnight does not exist here
2026-09-07 01:00 -03:00   &amp;lt;- and it never comes back
2026-09-08 01:00 -03:00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every day after the transition now starts an hour late. A night recorded at 00:30 falls outside the day it belongs to, and the calendar cell for that day is empty while the previous one holds two.&lt;/p&gt;

&lt;p&gt;The fix is one line and it is the least obvious line in the codebase:&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;$noon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$firstDay&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setTimezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$zone&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;setTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&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="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&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="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nv"&gt;$length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$days&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$noon&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;modify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'+'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;' days'&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;setTime&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;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;&lt;strong&gt;Walk from noon, offset from a fixed origin rather than accumulating, then drop to midnight at the end.&lt;/strong&gt; Noon is never skipped by a transition, offsetting from the origin means an error cannot compound, and the final &lt;code&gt;setTime(0, 0)&lt;/code&gt; renormalises to a midnight that actually exists. The same trick guards the cycle anchor, for the same reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A date is not an instant, and the type system will not tell you
&lt;/h2&gt;

&lt;p&gt;Three fields in the custody rule are &lt;em&gt;days&lt;/em&gt;, not moments: the anchor date, the start date, the end date. They are &lt;code&gt;Y-m-d&lt;/code&gt;, they have no time, and giving them one is how you lose a day.&lt;/p&gt;

&lt;p&gt;They are stored at UTC midnight and read back by their &lt;code&gt;Y-m-d&lt;/code&gt; only. In Symfony forms, the date field carries &lt;strong&gt;both&lt;/strong&gt; &lt;code&gt;model_timezone&lt;/code&gt; and &lt;code&gt;view_timezone&lt;/code&gt; set to &lt;code&gt;UTC&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;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'anchorDate'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;DateType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'model_timezone'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'UTC'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'view_timezone'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'UTC'&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;Set only one and a plain round trip through the form, with no edit at all, walks the date back by a day for anyone west of Greenwich. Save the form twice, lose two days, and the whole predicted calendar shifts under a family that changed nothing.&lt;/p&gt;

&lt;p&gt;The handover time gets the same treatment from the other direction: it is a &lt;code&gt;smallint&lt;/code&gt; of minutes since midnight, not a &lt;code&gt;time&lt;/code&gt; column. 18:00 is &lt;code&gt;1080&lt;/code&gt;. It is not an instant, it has no date, and there is no timezone to attach to it. A &lt;code&gt;time&lt;/code&gt; column invites exactly the conversion that must never happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The one line that silently poisons all of the above
&lt;/h2&gt;

&lt;p&gt;None of this holds if PHP disagrees with you about what timezone the database is speaking.&lt;/p&gt;

&lt;p&gt;Doctrine's &lt;code&gt;datetime_immutable&lt;/code&gt; type reads a &lt;code&gt;timestamp&lt;/code&gt; column with &lt;strong&gt;PHP's default timezone&lt;/strong&gt;. My server runs &lt;code&gt;Europe/Paris&lt;/code&gt;. So a value written as UTC comes back as a Paris wall clock reading of the same digits, which is a different instant:&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="nb"&gt;date_default_timezone_set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Europe/Paris'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DateTimeImmutable&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;createFromFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Y-m-d H:i:s'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2026-08-12 23:30:00'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Y-m-d H:i P'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                          &lt;span class="c1"&gt;// 2026-08-12 23:30 +02:00&lt;/span&gt;
&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setTimezone&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;DateTimeZone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'UTC'&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;format&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// 2026-08-12 21:30 +00:00&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two hours early, on every single row, in summer only. A handover recorded at 00:30 UTC is read as 22:30 UTC the day before: the night lands on the wrong day and is credited to the wrong parent. Nothing throws. The tests pass, because the test environment happened to agree with itself.&lt;/p&gt;

&lt;p&gt;So the kernel says it out loud, before anything else boots:&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;__construct&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;$environment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nv"&gt;$debug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;date_default_timezone_set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'UTC'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$environment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$debug&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;Display is the only place that leaves UTC, and it leaves for the &lt;em&gt;family's&lt;/em&gt; timezone, not the server's.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I know any of this holds
&lt;/h2&gt;

&lt;p&gt;The three services that do the counting (the cycle, the timeline, the statistics) know nothing about Doctrine, entities or repositories. They take a list of durations, an anchor date, a switch time, a timezone, and they return values. No fixtures, no database, no HTTP.&lt;/p&gt;

&lt;p&gt;That is not architectural taste. It is what makes the two properties worth asserting cheap enough to assert everywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A week across a transition is still seven nights, and the handover is still 18:00 on both sides.&lt;/strong&gt; One test, both directions, spring and autumn.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regenerating the plan twice produces exactly the same plan.&lt;/strong&gt; Cycle segments are always generated whole, never clipped to "now", so nothing shrinks by a sliver on each pass. The day this stops being true, the predicted calendar starts moving on its own.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An off-by-one in a date library shows up as a stack trace. An off-by-one in a custody calendar shows up as a parent driving to a school where nobody is waiting. Same bug class, very different failure mode, and it is the reason all of the above got written down instead of remembered.&lt;/p&gt;

&lt;p&gt;The app is at &lt;a href="https://nestido.com" rel="noopener noreferrer"&gt;nestido.com&lt;/a&gt;, and the reasoning behind the night as the unit of account is written up for parents rather than for developers in &lt;a href="https://nestido.com/blog/counting-overnights-co-parenting" rel="noopener noreferrer"&gt;counting overnights without an argument&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>php</category>
      <category>symfony</category>
      <category>datetime</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Cloudflare was 403-ing ChatGPT, Perplexity and Claude on my site, and my logs never knew</title>
      <dc:creator>Eric Mollenthiel</dc:creator>
      <pubDate>Sun, 09 Aug 2026 05:50:43 +0000</pubDate>
      <link>https://dev.to/mollenthiel/cloudflare-was-403-ing-chatgpt-perplexity-and-claude-on-my-site-and-my-logs-never-knew-5g8a</link>
      <guid>https://dev.to/mollenthiel/cloudflare-was-403-ing-chatgpt-perplexity-and-claude-on-my-site-and-my-logs-never-knew-5g8a</guid>
      <description>&lt;p&gt;For three weeks I wrote content aimed squarely at answer engines. An &lt;code&gt;llms.txt&lt;/code&gt;,&lt;br&gt;
FAQPage JSON-LD on two pages, a comparison page built to be quotable, a "how it&lt;br&gt;
works" page with HowTo markup. The site is a small dating app I run on my own&lt;br&gt;
server, &lt;a href="https://www.loviam.com" rel="noopener noreferrer"&gt;loviam.com&lt;/a&gt;, Symfony and PostgreSQL on a single&lt;br&gt;
box behind Cloudflare.&lt;/p&gt;

&lt;p&gt;Referrals stayed at zero. The only external referrer my analytics table had ever&lt;br&gt;
recorded, over its whole history, was &lt;code&gt;chatgpt.com&lt;/code&gt;: three visits, one day in&lt;br&gt;
July, never seen again.&lt;/p&gt;

&lt;p&gt;I assumed the content was not good enough. The robots had never read it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The five minute probe that should have been the first thing I did
&lt;/h2&gt;

&lt;p&gt;Nothing in my usual instruments could see the problem, so I stopped looking at&lt;br&gt;
them and asked the site directly, once per user agent, at its real public&lt;br&gt;
address:&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="k"&gt;for &lt;/span&gt;ua &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"Mozilla/5.0 (compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot)"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"Mozilla/5.0 (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"Mozilla/5.0 (compatible; Claude-SearchBot/1.0; +claudebot@anthropic.com)"&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;code&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;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}'&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ua&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://www.loviam.com/&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;  &lt;/span&gt;&lt;span class="nv"&gt;$ua&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;200  Googlebot
403  OAI-SearchBot
403  PerplexityBot
403  Claude-SearchBot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same result for &lt;code&gt;ChatGPT-User&lt;/code&gt;, &lt;code&gt;Claude-User&lt;/code&gt; and &lt;code&gt;MistralAI-User&lt;/code&gt;. Meanwhile&lt;br&gt;
&lt;code&gt;bingbot&lt;/code&gt;, &lt;code&gt;DuckDuckBot&lt;/code&gt;, &lt;code&gt;Applebot&lt;/code&gt;, &lt;code&gt;YandexBot&lt;/code&gt; and an ordinary browser all&lt;br&gt;
got a 200.&lt;/p&gt;

&lt;p&gt;The 403 body was eight bytes long, &lt;code&gt;blocked.&lt;/code&gt;, with &lt;code&gt;server: cloudflare&lt;/code&gt; in the&lt;br&gt;
headers, and there was &lt;strong&gt;no matching line in the Apache access log&lt;/strong&gt;. The refusal&lt;br&gt;
happened at the edge. My origin never heard about it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why every instrument I had was blind to this
&lt;/h2&gt;

&lt;p&gt;This is the part worth stealing, because the failure mode generalises.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Application logs cannot record a request that never arrives.&lt;/strong&gt; Obvious once&lt;br&gt;
said, easy to forget when you are grepping the access log for &lt;code&gt;PerplexityBot&lt;/code&gt;&lt;br&gt;
and concluding "it never came".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First party analytics is worse than blind, it is reassuring.&lt;/strong&gt; My beacon fires&lt;br&gt;
from the page. A robot that gets a 403 never gets the page, so it never appears,&lt;br&gt;
so the dashboard looks exactly like "nobody is interested".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My deployment smoke tests talked to the wrong server.&lt;/strong&gt; They run with&lt;br&gt;
&lt;code&gt;curl --resolve www.loviam.com:443:127.0.0.1&lt;/code&gt;, which is the right call for a&lt;br&gt;
deployment gate: you want to test the code you just shipped, not the CDN cache.&lt;br&gt;
But it means the whole test suite speaks to Apache directly and Cloudflare is&lt;br&gt;
invisible to it by construction. Thirty-eight green checks, every deploy, on a&lt;br&gt;
path no visitor uses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the managed &lt;code&gt;robots.txt&lt;/code&gt; actively pointed the wrong way.&lt;/strong&gt; Cloudflare&lt;br&gt;
injects its own block above yours. Mine named &lt;code&gt;GPTBot&lt;/code&gt;, &lt;code&gt;ClaudeBot&lt;/code&gt;, &lt;code&gt;CCBot&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;Bytespider&lt;/code&gt;, &lt;code&gt;Amazonbot&lt;/code&gt;, &lt;code&gt;meta-externalagent&lt;/code&gt;, &lt;code&gt;Google-Extended&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;Applebot-Extended&lt;/code&gt;, all of them &lt;em&gt;training&lt;/em&gt; crawlers. Blocking those is a&lt;br&gt;
defensible editorial decision and I stand by it.&lt;/p&gt;

&lt;p&gt;But the HTTP filter was blocking something else: the &lt;em&gt;search and on-demand&lt;br&gt;
reading&lt;/em&gt; robots, which were not named in that file at all. So a perfectly&lt;br&gt;
obedient crawler read "you are allowed", requested the page, and got a 403. That&lt;br&gt;
mismatch between the two settings is the tell. It is not a policy, it is a&lt;br&gt;
misconfiguration, and reading either file alone will never show it to you.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix is one toggle, and it is not the one named after crawlers
&lt;/h2&gt;

&lt;p&gt;Cloudflare dashboard, zone level: &lt;strong&gt;Security → Settings → Block AI bots&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Not "AI Crawl Control", which is where I looked first and where the interesting&lt;br&gt;
per-crawler table lives. The switch that returns the 403 is the plain one in&lt;br&gt;
Security Settings, and it treats "AI bot" as a single category: the crawler that&lt;br&gt;
builds ChatGPT's search index and the crawler that scrapes you for training data&lt;br&gt;
are the same thing to it, even though for a publisher they are close to&lt;br&gt;
opposites. One sends you traffic. The other does not.&lt;/p&gt;

&lt;p&gt;Also worth checking afterwards: the managed &lt;code&gt;robots.txt&lt;/code&gt; block, so it does not&lt;br&gt;
still name a robot you have just decided to let through.&lt;/p&gt;
&lt;h2&gt;
  
  
  The guard I put in, and why it is a timer and not a test
&lt;/h2&gt;

&lt;p&gt;What broke here is not my code. It is an edge configuration that can change&lt;br&gt;
without a deploy, from a dashboard, possibly by a provider default I never&lt;br&gt;
chose. A test in CI would only prove it was fine at build time.&lt;/p&gt;

&lt;p&gt;So it is a daily command instead, &lt;code&gt;app:seo:check-crawlers&lt;/code&gt;, which:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;probes the &lt;strong&gt;real public URL&lt;/strong&gt;, through Cloudflare, never &lt;code&gt;--resolve&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;walks sixteen user agents in three families: search, answer engines, training;&lt;/li&gt;
&lt;li&gt;checks two independent things per robot, the actual HTTP status &lt;strong&gt;and&lt;/strong&gt; whether
the served &lt;code&gt;robots.txt&lt;/code&gt; names it in a &lt;code&gt;Disallow&lt;/code&gt;, because those two disagreeing
is the exact signature I missed;&lt;/li&gt;
&lt;li&gt;keeps an ordinary browser user agent as a control, so that "the robots are
blocked" is never confused with "the site is down";&lt;/li&gt;
&lt;li&gt;fails for search and answer engines, and never for training crawlers, which
stay blocked on purpose.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A systemd timer runs it every morning and mails me &lt;strong&gt;only on state change&lt;/strong&gt;.&lt;br&gt;
An alert that fires every day is an alert nobody reads.&lt;/p&gt;
&lt;h2&gt;
  
  
  The second thing Cloudflare was doing, which was worse
&lt;/h2&gt;

&lt;p&gt;While I was in there, I checked what else the proxy changed about requests, and&lt;br&gt;
found the site had been taking itself down for weeks.&lt;/p&gt;

&lt;p&gt;Every request reached Apache carrying the IP of a Cloudflare relay.&lt;br&gt;
&lt;code&gt;mod_remoteip&lt;/code&gt; was loaded but never configured, so nothing restored the real&lt;br&gt;
client address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;172.71.135.63 - - [25/Jul/2026:12:21:03 +0000] "GET /fr/ HTTP/2.0" 200 10399
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;fail2ban reads those logs. So fail2ban was banning Cloudflare's own relays.&lt;br&gt;
Cloudflare could then no longer reach my origin, and served &lt;strong&gt;HTTP 521 to every&lt;br&gt;
visitor routed through that relay&lt;/strong&gt;. The correlation was exact, to the second:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;18/07 14:50:39  fail2ban  [apache-auth] Ban 141.101.98.192   (a Cloudflare range)
18/07 14:50:39  uptime probe: DOWN (HTTP 521)
18/07 15:49     unban cascade
18/07 15:54:02  uptime probe: RECOVERED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five episodes in one week, the longest 42 minutes. My &lt;code&gt;apache-auth&lt;/code&gt; jail triggers&lt;br&gt;
at &lt;code&gt;maxretry = 2&lt;/code&gt;, and my staging host sits behind htpasswd and therefore&lt;br&gt;
manufactures 401s: two fat-fingered logins behind a shared relay were enough to&lt;br&gt;
take production down for everyone else behind it.&lt;/p&gt;

&lt;p&gt;Two quieter casualties of the same root cause: every rate limiter keyed on&lt;br&gt;
&lt;code&gt;$request-&amp;gt;getClientIp()&lt;/code&gt; was bucketing the entire planet into a handful of&lt;br&gt;
relays, and the visitor hash behind my "unique visitors" number was hashing the&lt;br&gt;
relay, not the visitor. The weekly figure I had been reading to judge growth was&lt;br&gt;
not counting what I thought it counted.&lt;/p&gt;

&lt;p&gt;The fix is to trust &lt;code&gt;CF-Connecting-IP&lt;/code&gt;, &lt;strong&gt;but only when the request comes from a&lt;br&gt;
published Cloudflare range&lt;/strong&gt;. Without that restriction anyone could forge the&lt;br&gt;
header and walk straight past your rate limiting and your bans. Second layer, as&lt;br&gt;
a net: put those same ranges in fail2ban's &lt;code&gt;ignoreip&lt;/code&gt;, so that if the first layer&lt;br&gt;
ever stops working you ban nobody rather than banning your own CDN. Both files&lt;br&gt;
are generated by a monthly script, because the ranges do move, and a frozen list&lt;br&gt;
would quietly reopen the trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell myself in June
&lt;/h2&gt;

&lt;p&gt;If something sits between your users and your server, &lt;strong&gt;test through it, with&lt;br&gt;
the user agent of the thing you care about&lt;/strong&gt;. Not from your laptop, not with&lt;br&gt;
&lt;code&gt;--resolve&lt;/code&gt;, not from the logs. The whole class of bug here is that the failure&lt;br&gt;
happens in a place none of your instruments can observe, and every instrument&lt;br&gt;
you own will report a calm, plausible, completely wrong "nobody came".&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>seo</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>Splitting €10 three ways: the largest remainder method, and the tiebreak everyone forgets</title>
      <dc:creator>Eric Mollenthiel</dc:creator>
      <pubDate>Fri, 07 Aug 2026 23:44:05 +0000</pubDate>
      <link>https://dev.to/mollenthiel/splitting-eu10-three-ways-the-largest-remainder-method-and-the-tiebreak-everyone-forgets-4aag</link>
      <guid>https://dev.to/mollenthiel/splitting-eu10-three-ways-the-largest-remainder-method-and-the-tiebreak-everyone-forgets-4aag</guid>
      <description>&lt;p&gt;Split €10 three ways and you get €3.33, €3.33, €3.33. That is €9.99. One cent has gone missing, and you now have to decide, in code, who pays it.&lt;/p&gt;

&lt;p&gt;It sounds like a rounding detail. It is not: it is the difference between an app whose numbers close and an app whose numbers almost close. I hit it while building an expense-sharing app, and the fix turned out to be a voting-theory algorithm from the 1790s.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three wrong answers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Round each share and hope.&lt;/strong&gt; &lt;code&gt;round(1000 / 3) = 333&lt;/code&gt; per person, 999 total. You are one cent short of the expense. Every balance downstream inherits that error, and it compounds: fifty three-way expenses and the group's books are off by fifty cents with no line item to point at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Give the remainder to the largest share.&lt;/strong&gt; This is the common fix, and it is fine at €10. It is not fine when the split is uneven. If someone entered exact amounts that do not add up to the total, "absorb the difference on the biggest share" silently decides that one person pays €15 more than they typed. Three cents of rounding go unnoticed. Fifteen euros go unnoticed too, right up until someone checks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use floats.&lt;/strong&gt; &lt;code&gt;0.1 + 0.2 != 0.3&lt;/code&gt;. You know this. The whole domain is integer cents or nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The right answer is a 1792 apportionment method
&lt;/h2&gt;

&lt;p&gt;The problem (divide a whole number of indivisible units proportionally to weights) is the same problem as allocating seats in a parliament to parties by vote share. Alexander Hamilton proposed a solution for the US House of Representatives in 1792. It is called the &lt;strong&gt;largest remainder method&lt;/strong&gt;, and it is three steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Compute each participant's exact (fractional) share.&lt;/li&gt;
&lt;li&gt;Give everyone the floor of it.&lt;/li&gt;
&lt;li&gt;Hand the leftover units, one each, to whoever has the largest fractional remainder.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For €10 among three people: exact share is 333.33 cents each, floor is 333, allocated is 999, one cent left over. All three remainders are 0.33, so one of them gets the extra cent: 334 / 333 / 333.&lt;/p&gt;

&lt;p&gt;The sum is exactly 1000. Always. Not approximately.&lt;/p&gt;

&lt;p&gt;Here is the core of it, in PHP, working entirely in integer cents:&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="cd"&gt;/**
 * @param array&amp;lt;int, float&amp;gt; $weights  participant id =&amp;gt; weight
 * @return array&amp;lt;int, int&amp;gt;            participant id =&amp;gt; cents
 */&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;prorate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$totalCents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$weights&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$weights&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;$sum&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&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;\InvalidArgumentException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'No shares to split.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$amounts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="nv"&gt;$remainders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="nv"&gt;$allocated&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;$weights&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$weight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$exact&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$totalCents&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$weight&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nv"&gt;$sum&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$floor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$exact&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$amounts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$floor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$remainders&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$exact&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$floor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$allocated&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nv"&gt;$floor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$totalCents&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$allocated&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;$left&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="nv"&gt;$order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$remainders&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nb"&gt;usort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$remainders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$weights&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$cmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$remainders&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$remainders&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$a&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;$cmp&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="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$cmp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="nv"&gt;$cmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$weights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$weights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$a&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;$cmp&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$cmp&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$a&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;array_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$left&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;$id&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;$amounts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$id&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;return&lt;/span&gt; &lt;span class="nv"&gt;$amounts&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;$left&lt;/code&gt; is bounded by the number of participants minus one, so this is never more than a handful of increments.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tiebreak is the part people skip
&lt;/h2&gt;

&lt;p&gt;Look at the &lt;code&gt;usort&lt;/code&gt; comparator. It does not stop at comparing remainders. When two remainders are equal, which is exactly what happens in the €10-among-three case and is the case you will hit most often, it falls through to the weight, and then to the participant id.&lt;/p&gt;

&lt;p&gt;Without that fallback you have a &lt;strong&gt;non-deterministic&lt;/strong&gt; split. PHP's &lt;code&gt;usort&lt;/code&gt; is not stable across all inputs, and even a stable sort leaves you at the mercy of insertion order. The same expense, recalculated after an edit, can hand the cent to someone else. Balances shift by a cent for no visible reason. Someone notices, does not trust the app any more, and they are right not to.&lt;/p&gt;

&lt;p&gt;So the rule is: the tiebreak chain must terminate in something total and immutable. The id works. "Whoever was added to the group first" works. "Whatever order the hash table gave me" does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this buys you: an invariant you can assert
&lt;/h2&gt;

&lt;p&gt;Once every split sums exactly to its expense, a much stronger property falls out of the model for free. Each participant's balance is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;balance = what they paid - what they owe + what they sent - what they received
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sum that across every member of a group and every term cancels: every euro paid is owed by someone, every transfer sent is received. &lt;strong&gt;The balances of a group always sum to exactly zero.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is not a nice-to-have, it is a test oracle. It turns "did I get the money maths right" into a single assertion you can run after every operation:&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;testBalancesAlwaysSumToZero&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;$balances&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;calculator&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;forGroup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$group&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="nf"&gt;assertSame&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="nb"&gt;array_sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Balance&lt;/span&gt; &lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;cents&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="nv"&gt;$balances&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;Any bug that loses or invents a cent anywhere trips this: a bad split, a mishandled refund, a currency conversion, a deleted participant. It is the cheapest high-value test in the codebase, and it only exists because the splitter is exact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three edge cases worth stealing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Negative totals.&lt;/strong&gt; Refunds and corrections are negative expenses. &lt;code&gt;floor(-333.33)&lt;/code&gt; is &lt;code&gt;-334&lt;/code&gt;, not &lt;code&gt;-333&lt;/code&gt;, so the remainder logic inverts and you over-allocate. Take the absolute value, split that, negate at the end. Two lines, and it stops a whole category of sign bugs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exact amounts should refuse, not repair.&lt;/strong&gt; If a mode lets people type each share by hand and the total does not match, do not silently fix it. Reject the input and name the gap: "you entered €85.00, the expense is €100.00, €15.00 missing". Someone who does not want to do the arithmetic has the other modes. Someone who does want to do it deserves to be told they got it wrong rather than have it quietly rewritten.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zero-decimal currencies.&lt;/strong&gt; Store everything in hundredths regardless. ¥1,500 is 150000. Then one integer travels the entire calculation without ever needing to know what currency it is, and formatting stays a presentation concern where it belongs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this came from
&lt;/h2&gt;

&lt;p&gt;I ran into all of this building &lt;a href="https://kotisso.com" rel="noopener noreferrer"&gt;Kotisso&lt;/a&gt;, a shared-expense tracker for flatshares, group holidays and separated parents. The zero-sum invariant is the whole design: everything else in the app is arranged so that it cannot be violated.&lt;/p&gt;

&lt;p&gt;The largest remainder method is old, well-studied, and takes about thirty lines. If you are dividing indivisible units by proportion anywhere (money, seats, inventory, rate limits) it is probably the algorithm you want, and the tiebreak is probably the part you are about to forget.&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>programming</category>
      <category>software</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Encrypting personal data at rest in Symfony: a 60-line Doctrine type, and the four things it breaks</title>
      <dc:creator>Eric Mollenthiel</dc:creator>
      <pubDate>Wed, 05 Aug 2026 11:27:59 +0000</pubDate>
      <link>https://dev.to/mollenthiel/encrypting-personal-data-at-rest-in-symfony-a-60-line-doctrine-type-and-the-four-things-it-breaks-2g6b</link>
      <guid>https://dev.to/mollenthiel/encrypting-personal-data-at-rest-in-symfony-a-60-line-doctrine-type-and-the-four-things-it-breaks-2g6b</guid>
      <description>&lt;p&gt;A review form asked me a yes/no question I could not answer with yes: &lt;em&gt;do you encrypt personal data at rest and in transit?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In transit, sure, TLS everywhere, that part is free in 2026. At rest, no. The app runs on a plain VPS with no full-disk encryption, and the database held customer names, addresses and email addresses in a &lt;code&gt;json&lt;/code&gt; column, in clear, exactly as PostgreSQL received them.&lt;/p&gt;

&lt;p&gt;There are two ways out of that question. One is to click yes and move on, because everybody knows the reviewer will not &lt;code&gt;ssh&lt;/code&gt; into your box. The other is to make the answer true. This post is the second one, taken from &lt;a href="https://invoicepilot.shipanvil.com/" rel="noopener noreferrer"&gt;InvoicePilot&lt;/a&gt;, a Shopify app that issues invoices and therefore stores, by definition, the buyer's identity.&lt;/p&gt;

&lt;p&gt;The encryption itself is the easy part, and it is about sixty lines. What follows it is the interesting part: the moment a column becomes an opaque blob, four things you took for granted stop working, and two of them will not fail loudly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why application-level encryption at all
&lt;/h2&gt;

&lt;p&gt;The usual objection is fair: if an attacker gets your database, they probably have your application server too, and the key lives there. So what is the point?&lt;/p&gt;

&lt;p&gt;The point is that "the attacker has your whole box" is one threat among several, and the cheaper ones are more common:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a database dump copied to a laptop, a CI job, or a support ticket;&lt;/li&gt;
&lt;li&gt;a backup file on object storage with the wrong ACL;&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;SELECT&lt;/code&gt; run by a future you at 2am, against production, with the output scrolling into a terminal buffer that gets pasted somewhere;&lt;/li&gt;
&lt;li&gt;a restored snapshot handed to a contractor for debugging.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Application-level encryption does nothing against a full host compromise and everything against those four. That is a real gain, and it is the gain the compliance question is actually asking about. Nothing here protects you from yourself if you keep the key in the same dump as the data, which is the mistake at the end of this post.&lt;/p&gt;

&lt;p&gt;Full-disk encryption solves a different problem entirely: a stolen physical drive. On a rented VM, where the disk is a network volume you do not control and the machine boots unattended, it protects roughly nothing that matters. So on a VPS, column-level encryption is not the cheap approximation of disk encryption. It is the one that fits the threat.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cipher: libsodium, no bundle
&lt;/h2&gt;

&lt;p&gt;PHP has had libsodium in core since 7.2. A secretbox is authenticated encryption (XSalsa20 for confidentiality, Poly1305 for integrity), it takes a 32-byte key and a nonce, and there is no way to hold it wrong except reusing a nonce, which &lt;code&gt;random_bytes()&lt;/code&gt; makes a non-issue.&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="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TokenCipher&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;string&lt;/span&gt; &lt;span class="nv"&gt;$key&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="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$base64Key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;base64_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$base64Key&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="no"&gt;\SODIUM_CRYPTO_SECRETBOX_KEYBYTES&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nf"&gt;\strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\InvalidArgumentException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s1"&gt;'The encryption key must be 32 random bytes, base64-encoded.'&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;encrypt&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;$plaintext&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="nv"&gt;$nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;random_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;\SODIUM_CRYPTO_SECRETBOX_NONCEBYTES&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;base64_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$nonce&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;sodium_crypto_secretbox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$plaintext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$nonce&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;key&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;decrypt&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;$encoded&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="nv"&gt;$raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;base64_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$encoded&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$raw&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;\strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="no"&gt;\SODIUM_CRYPTO_SECRETBOX_NONCEBYTES&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;\RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Invalid ciphertext.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$raw&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="no"&gt;\SODIUM_CRYPTO_SECRETBOX_NONCEBYTES&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$plaintext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sodium_crypto_secretbox_open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;\SODIUM_CRYPTO_SECRETBOX_NONCEBYTES&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nv"&gt;$nonce&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;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$plaintext&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;\RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Decryption failed (wrong key or corrupted data).'&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;$plaintext&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;Two details worth stating out loud, because they are what people get wrong when they roll this by hand:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The nonce travels with the ciphertext.&lt;/strong&gt; It is not a secret, it must just never repeat under the same key. Prepending 24 random bytes and slicing them off on the way back means you never have to store or manage a nonce column.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;sodium_crypto_secretbox_open()&lt;/code&gt; returning &lt;code&gt;false&lt;/code&gt; is not a decoding error, it is a failed authentication.&lt;/strong&gt; The value was truncated, corrupted or tampered with. Throwing there is the whole point of using an AEAD construction instead of raw &lt;code&gt;openssl_encrypt&lt;/code&gt; with a mode you picked from a Stack Overflow answer.&lt;/p&gt;

&lt;p&gt;I generate the key with one line and put it in the environment, next to the database URL:&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;php&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="s2"&gt;"echo base64_encode(random_bytes(32)), PHP_EOL;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Doctrine type
&lt;/h2&gt;

&lt;p&gt;Now the part that makes it disappear from the rest of the codebase. Doctrine's custom types sit exactly at the boundary you want: one hook on the way to the database, one on the way back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EncryptedJsonType&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Type&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;string&lt;/span&gt; &lt;span class="no"&gt;NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'encrypted_json'&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;static&lt;/span&gt; &lt;span class="kt"&gt;?TokenCipher&lt;/span&gt; &lt;span class="nv"&gt;$cipher&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="cd"&gt;/** Test seam: lets a test drive the type without touching the environment. */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;setCipher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;?TokenCipher&lt;/span&gt; &lt;span class="nv"&gt;$cipher&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="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nv"&gt;$cipher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$cipher&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;getName&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="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&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;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getSQLDeclaration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$column&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;AbstractPlatform&lt;/span&gt; &lt;span class="nv"&gt;$platform&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$platform&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getClobTypeDeclarationSQL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$column&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;convertToDatabaseValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;AbstractPlatform&lt;/span&gt; &lt;span class="nv"&gt;$platform&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;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;$value&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;null&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;cipher&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;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;\JSON_THROW_ON_ERROR&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;convertToPHPValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;AbstractPlatform&lt;/span&gt; &lt;span class="nv"&gt;$platform&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="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="nf"&gt;\is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$value&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;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// Rows written before the switch are still plain JSON.&lt;/span&gt;
        &lt;span class="nv"&gt;$json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'{'&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$value&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="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'['&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$value&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="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$value&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;cipher&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;decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;json_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$json&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="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;\JSON_THROW_ON_ERROR&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="nf"&gt;\is_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$decoded&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;\RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'An encrypted JSON column must hold an object.'&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;$decoded&lt;/span&gt;&lt;span class="p"&gt;;&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;function&lt;/span&gt; &lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;TokenCipher&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Doctrine builds its types statically, outside the container: read the&lt;/span&gt;
        &lt;span class="c1"&gt;// key the way Dotenv exposes it to every process (bin/console, FPM,&lt;/span&gt;
        &lt;span class="c1"&gt;// the messenger worker and migrations alike).&lt;/span&gt;
        &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$_SERVER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ENCRYPTION_KEY'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nv"&gt;$_ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ENCRYPTION_KEY'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&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="nv"&gt;$cipher&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;TokenCipher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;\is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&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;Registered once:&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;doctrine&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;dbal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;encrypted_json&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;App\Doctrine\EncryptedJsonType&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And used like any other type, which is the whole payoff. Everything above the entity, the repositories, the controllers, the PDF renderer, the tests, keeps handling a plain PHP 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="cd"&gt;/** @var array&amp;lt;string, mixed&amp;gt; */&lt;/span&gt;
&lt;span class="na"&gt;#[ORM\Column(type: EncryptedJsonType::NAME)]&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$buyer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three decisions in there deserve a sentence each.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The type reads &lt;code&gt;$_SERVER&lt;/code&gt; directly, and that is not laziness.&lt;/strong&gt; Doctrine instantiates types through a static registry, before and outside the service container. There is no constructor injection available, and there is no container to ask. Reading the same superglobal that Symfony's Dotenv component populates is what makes the type behave identically under FPM, &lt;code&gt;bin/console&lt;/code&gt;, a Messenger worker and a migration, which is exactly the set of processes that will touch this column.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The static setter is a test seam, and it earns its keep.&lt;/strong&gt; A unit test sets a fixed key, exercises the round trip, and asserts on the stored value:&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;testTheStoredValueLeaksNothing&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;$stored&lt;/span&gt; &lt;span class="o"&gt;=&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;type&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="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;BUYER&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;platform&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="nf"&gt;assertStringNotContainsString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Claire'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$stored&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="nf"&gt;assertStringNotContainsString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'example.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$stored&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second assertion is the one I would keep if I could only keep one. It is the test that fails the day someone "simplifies" the type back to a plain JSON column, and it is worth more than any amount of documentation saying please do not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reading tolerates plaintext, writing never produces it.&lt;/strong&gt; A JSON document always starts with &lt;code&gt;{&lt;/code&gt; or &lt;code&gt;[&lt;/code&gt;; a base64 ciphertext, in practice, does not. That single-byte check buys a deploy where old rows and new rows coexist, and it is the difference between a migration you can run calmly and one that has to be atomic with the code deploy.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update, 9 August 2026.&lt;/strong&gt; A reader pushed back on that paragraph, and the objection holds: detecting plaintext by its first byte leaves the storage format ambiguous &lt;em&gt;forever&lt;/em&gt;. A row written through another path, or edited by anyone holding a database write, opts out of decryption merely by looking like JSON. That is a permission check spelled as a guess. The fix is to record the format instead of inferring it: every value now reads &lt;code&gt;enc:v1:&amp;lt;key id&amp;gt;:&amp;lt;base64&amp;gt;&lt;/code&gt;, anything else is refused, and the key id turns the "prefix a key id now" advice further down this post into an actual rotation path. The section &lt;em&gt;The envelope, and why the first byte was a mistake&lt;/em&gt;, near the end, has the code and what it costs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Migrating the rows you already have
&lt;/h2&gt;

&lt;p&gt;Changing the column type is one line. Rewriting the existing rows is the migration, and Doctrine Migrations has the right hook for it: &lt;code&gt;postUp()&lt;/code&gt; runs after the schema change, with the new type available.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;up&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Schema&lt;/span&gt; &lt;span class="nv"&gt;$schema&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addSql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ALTER TABLE invoice ALTER buyer TYPE TEXT'&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;postUp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Schema&lt;/span&gt; &lt;span class="nv"&gt;$schema&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$type&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="nc"&gt;EncryptedJsonType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;NAME&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&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, buyer FROM invoice'&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;$buyer&lt;/span&gt; &lt;span class="o"&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;'buyer'&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="nf"&gt;\is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$buyer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$buyer&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'{'&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$buyer&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="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="s1"&gt;'['&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$buyer&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;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// already encrypted&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;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;'invoice'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'buyer'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$type&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="nb"&gt;json_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$buyer&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;$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="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;Note &lt;code&gt;iterateAssociative()&lt;/code&gt; rather than &lt;code&gt;fetchAllAssociative()&lt;/code&gt;: the migration streams, so the memory it uses does not depend on how many rows you have. And the same loop lives in a console command (&lt;code&gt;app:encrypt-buyers&lt;/code&gt;), because the migration is not the only way a plaintext row can appear. A rollback to a previous release, or a dump restored from before the switch, both produce rows the migration will never see again. Having a command means the answer to "prove the database holds no readable personal data" is something you run, not something you believe.&lt;/p&gt;

&lt;p&gt;Write the &lt;code&gt;down()&lt;/code&gt; too, and make it decrypt. A migration you cannot reverse is a deploy you cannot abort.&lt;/p&gt;

&lt;h2&gt;
  
  
  What breaks, part 1: you cannot query it any more
&lt;/h2&gt;

&lt;p&gt;This is the obvious one, and it is still the one that bites.&lt;/p&gt;

&lt;p&gt;An encrypted column has no equality, no &lt;code&gt;LIKE&lt;/code&gt;, no index, no &lt;code&gt;ORDER BY&lt;/code&gt;, no &lt;code&gt;GROUP BY&lt;/code&gt;. Two rows holding the same email produce two different ciphertexts, because the nonce differs. Every predicate you had on that data is gone, and the compiler will not tell you.&lt;/p&gt;

&lt;p&gt;In my case exactly one query touched it, and it came from an obligation I could not drop: the GDPR data-request webhook, which must answer "what do you hold about this customer?" given an email address. It used to be a JSON predicate in SQL. It became a stream in PHP:&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="cd"&gt;/**
 * The buyer block is encrypted at rest, so no SQL predicate can match on
 * it: the rows are streamed and compared in PHP (case-insensitively, as
 * an email is). This runs on the GDPR customer webhooks only, never on a
 * merchant-facing page.
 *
 * @return list&amp;lt;string&amp;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;findNumbersByShopAndBuyerEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Shop&lt;/span&gt; &lt;span class="nv"&gt;$shop&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;$email&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$connection&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;getEntityManager&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;getConnection&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$type&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="nc"&gt;EncryptedJsonType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;NAME&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="nv"&gt;$needle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;mb_strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;trim&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;span class="nv"&gt;$numbers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="nv"&gt;$rows&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;iterateAssociative&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'SELECT number, buyer FROM invoice WHERE shop_id = :shop ORDER BY number'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'shop'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$shop&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getId&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$rows&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;$buyer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$type&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;convertToPHPValue&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;'buyer'&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="nv"&gt;$buyerEmail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;\is_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$buyer&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="nv"&gt;$buyer&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="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="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="nv"&gt;$number&lt;/span&gt; &lt;span class="o"&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;'number'&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_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$buyerEmail&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="nf"&gt;\is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$number&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="nb"&gt;mb_strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$buyerEmail&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$needle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$numbers&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$number&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;return&lt;/span&gt; &lt;span class="nv"&gt;$numbers&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;Yes, that is a full scan per tenant, decrypting as it goes. I am fine with it, and the docblock says why: it runs on a webhook that fires a handful of times a year, never on a page a merchant waits for. The scope is narrowed by &lt;code&gt;shop_id&lt;/code&gt;, which is indexed, so the scan is over one tenant's rows, not the table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The decision this really forces is upstream.&lt;/strong&gt; Before you encrypt a column, list every query that reads it, and sort them into "rare and allowed to be slow" and "on a hot path". If anything lands in the second pile, you have a genuine design choice to make, and none of the options are free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic encryption&lt;/strong&gt; for that one field (same input, same ciphertext, no nonce): equality queries and unique indexes come back, and you leak which rows share a value, plus you become vulnerable to frequency analysis on low-cardinality fields.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A blind index&lt;/strong&gt;: store &lt;code&gt;hash(hmac_key, normalize(value))&lt;/code&gt; in a second, indexed column, query on that, keep the real value encrypted. Equality only, but it is exact and fast. This is what I would build the day the lookup moves onto a hot path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not encrypt that field.&lt;/strong&gt; Encrypting the fields that carry identity and leaving a non-identifying one queryable is a defensible line, as long as you draw it deliberately and write down why.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What is not defensible is discovering the constraint after the migration, in production, because a page that used to filter now returns nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What breaks, part 2: your logs become the plaintext copy
&lt;/h2&gt;

&lt;p&gt;This one is quiet, and it is the one I would go looking for first in someone else's codebase.&lt;/p&gt;

&lt;p&gt;The same compliance form that asks about encryption also asks for access logs: prove you know who read personal data and when. The obvious implementation logs the record. Do that and you have carefully encrypted a column while writing its contents, in clear, into a file that is rotated, shipped to a log aggregator, and backed up by a completely different policy.&lt;/p&gt;

&lt;p&gt;So the audit trail names the actor and the object, never the data:&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="cd"&gt;/**
 * The trail names the shop, the document and the channel, never the
 * personal data itself: a log file must not become a second,
 * unencrypted copy of what the database encrypts.
 */&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;documentRead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Invoice&lt;/span&gt; &lt;span class="nv"&gt;$invoice&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;$channel&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;$format&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;logger&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="s1"&gt;'Document read.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'shop'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getShop&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;getShopDomain&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="s1"&gt;'document'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getNumber&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="s1"&gt;'channel'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 'admin' (the merchant) or 'buyer-link'&lt;/span&gt;
        &lt;span class="s1"&gt;'format'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$format&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;While you are there, check the three other places that leak the same way: exception context (an entity dumped into a stack trace), your error tracker's breadcrumbs, and the Symfony profiler in any environment where it is enabled against real data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What breaks, part 3: the backup that carries the key
&lt;/h2&gt;

&lt;p&gt;The failure I want to spell out, because it turns the whole exercise into theatre.&lt;/p&gt;

&lt;p&gt;Encrypting a column protects a dump only if the dump does not also contain the key. Mine did not, the key lives in an environment file, not in PostgreSQL. But the backup itself was a plain &lt;code&gt;pg_dump&lt;/code&gt; sitting on the same machine, and half of the app's personal data lives in columns that are not encrypted anyway, because they are the merchant's own data, not their customers'.&lt;/p&gt;

&lt;p&gt;So the dumps are now encrypted too, streaming, with the key readable only by root and the postgres user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pg_dump &lt;span class="nt"&gt;-Fc&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | zstd &lt;span class="nt"&gt;-T0&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | openssl enc &lt;span class="nt"&gt;-aes-256-ctr&lt;/span&gt; &lt;span class="nt"&gt;-pbkdf2&lt;/span&gt; &lt;span class="nt"&gt;-pass&lt;/span&gt; file:/etc/pg-backup.key &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$DB&lt;/span&gt;&lt;span class="s2"&gt;.dump.zst.enc"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to get right, and both are about the day you need it rather than the day you set it up. &lt;strong&gt;Test the restore path before you need it&lt;/strong&gt;, including the decryption, because an encrypted backup you cannot open is worse than no backup: it looks like a backup in the monitoring. And &lt;strong&gt;write down where the key is and who can read it&lt;/strong&gt;, somewhere that is not the encrypted backup, which sounds obvious right up to the moment you watch someone reason in a circle about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What breaks, part 4: key rotation, which you will not do
&lt;/h2&gt;

&lt;p&gt;Be honest about this one from day one. With the design above, rotating the key means decrypting every row with the old key and re-encrypting with the new one, which means both keys have to exist at once, which means the cipher needs a key-id prefix in the ciphertext, which nothing above has.&lt;/p&gt;

&lt;p&gt;I did not build that, and I decided so explicitly: one product, one key, rotation is a scripted maintenance window and I would rather do that once in five years than carry a key hierarchy from day one. If you are in a context where rotation is a scheduled control rather than an incident response, prefix a key id to the ciphertext now, when the column is empty. It is one byte of format and it costs nothing today.&lt;/p&gt;

&lt;p&gt;The compensating control is smaller than key rotation and worth more than it looks: the key is in the environment, so a leaked &lt;em&gt;dump&lt;/em&gt; never contains it, and a leaked &lt;em&gt;repository&lt;/em&gt; never contains it either. What would force a rotation is a compromised host, and on a compromised host, rotating the key is the last item on a long list.&lt;/p&gt;

&lt;h2&gt;
  
  
  The envelope, and why the first byte was a mistake
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Added 9 August 2026, after a comment on this post.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The version above recognised a legacy plaintext row by its first byte. The objection, in one sentence: that makes the storage format ambiguous forever, and the ambiguity is exploitable, because a row that merely &lt;em&gt;looks&lt;/em&gt; like JSON bypasses decryption whoever wrote it.&lt;/p&gt;

&lt;p&gt;The failure is not the encryption, it is that &lt;code&gt;convertToPHPValue()&lt;/code&gt; was asking the payload what it was. A payload is not a trustworthy narrator. The format and the key version belong next to the data, as data:&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;const&lt;/span&gt; &lt;span class="no"&gt;string&lt;/span&gt; &lt;span class="no"&gt;PREFIX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'enc:v1:'&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;convertToDatabaseValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;AbstractPlatform&lt;/span&gt; &lt;span class="nv"&gt;$platform&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;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;$value&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;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;$keyring&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="nf"&gt;keyring&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;PREFIX&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$keyring&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;activeId&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;':'&lt;/span&gt;
        &lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$keyring&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;active&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;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;\JSON_THROW_ON_ERROR&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;convertToPHPValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;AbstractPlatform&lt;/span&gt; &lt;span class="nv"&gt;$platform&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="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="nf"&gt;\is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$value&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;null&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="nf"&gt;str_starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&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;PREFIX&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Fail closed: a readable row is a bug or an intrusion, never a fallback.&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;\RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'This value is not in the "'&lt;/span&gt;&lt;span class="mf"&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;PREFIX&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'" envelope.'&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="nv"&gt;$keyId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$ciphertext&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="nb"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&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;decode&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="nf"&gt;keyring&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="nv"&gt;$keyId&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;decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ciphertext&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 things change, and only the first is obvious.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reading fails closed.&lt;/strong&gt; There is no longer a branch that returns plaintext. The cost is that a dump restored from before the switch stops the application instead of quietly serving personal data in clear, which is the trade I want on a column that exists because a compliance form asked about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The key id makes rotation real.&lt;/strong&gt; &lt;code&gt;get($keyId)&lt;/code&gt; reads from a keyring: one active key that writes, any number of retired keys that only read. Rotating is now three steps that never need a flag day (promote the new key, backfill, drop the old one from the keyring), and the day a key leaves, the failure is a message naming the missing key id rather than a MAC error you have to guess at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The backfill stops guessing too.&lt;/strong&gt; The conversion command tries decryption &lt;em&gt;first&lt;/em&gt;, with every key in the keyring, and only then treats the value as plain JSON. Base64 decoding of &lt;code&gt;{"name":...}&lt;/code&gt; fails outright, so plaintext never decrypts and a ciphertext is never mistaken for plaintext. The heuristic is gone from the read path &lt;em&gt;and&lt;/em&gt; from the migration path.&lt;/p&gt;

&lt;p&gt;And because the type now refuses anything unenveloped, the command doubles as the check that says whether the database is fully converted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bin/console app:encrypt-buyers &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;exits non-zero as long as one row is outside the envelope, which is a cron-shaped alert rather than a paragraph in a runbook.&lt;/p&gt;

&lt;p&gt;The whole change is about eighty lines including tests, and it was possible because the table held five rows. That is the real lesson, and it is the one this post got wrong by one step: &lt;em&gt;storage formats are not refactorable&lt;/em&gt;. Everything else in this codebase can be changed later. A format that is already in a million rows cannot, and the version prefix that would make it changeable costs seven bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that is not code
&lt;/h2&gt;

&lt;p&gt;Two hours of work and about a hundred lines. The reason to write it up is not that any of it is clever. It is that the whole thing was triggered by a yes/no question in a form, and the interesting decision was upstream of all the code: treating the form as an audit of what was true rather than a box to tick.&lt;/p&gt;

&lt;p&gt;That reframing found three false answers, not one. Encryption at rest was the first. Encrypted backups was the second, and it was a server-wide fix that now protects every other project on the same box. Access logging was the third, and it did not exist at all.&lt;/p&gt;

&lt;p&gt;If you sell software to businesses, you will meet that form. Shopify, Stripe, any enterprise procurement questionnaire, the ISO 27001 checklist your first serious customer sends you. The answers are cheaper to make true while the codebase is small, and the questions are a surprisingly good roadmap for a solo developer who cannot afford a security review.&lt;/p&gt;




&lt;p&gt;The app this comes from is &lt;a href="https://invoicepilot.shipanvil.com/" rel="noopener noreferrer"&gt;InvoicePilot&lt;/a&gt;, compliant invoices for Shopify merchants, built on &lt;a href="https://shipanvil.com/" rel="noopener noreferrer"&gt;ShipAnvil&lt;/a&gt;, which ships the auth, billing, admin and deploy pipeline these apps stand on. If you are wiring the Shopify side of things in PHP, the previous post covers &lt;a href="https://shipanvil.com/blog/shopify-embedded-app-symfony" rel="noopener noreferrer"&gt;the whole embedded-app authentication path in Symfony&lt;/a&gt;, and the foundations are in &lt;a href="https://shipanvil.com/blog/deploy-symfony-vps" rel="noopener noreferrer"&gt;deploying Symfony to a production VPS&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>php</category>
      <category>security</category>
      <category>database</category>
    </item>
    <item>
      <title>Building an embedded Shopify app in Symfony: session tokens, token exchange, and the parts nobody writes in PHP</title>
      <dc:creator>Eric Mollenthiel</dc:creator>
      <pubDate>Sun, 02 Aug 2026 23:01:29 +0000</pubDate>
      <link>https://dev.to/mollenthiel/building-an-embedded-shopify-app-in-symfony-session-tokens-token-exchange-and-the-parts-nobody-438g</link>
      <guid>https://dev.to/mollenthiel/building-an-embedded-shopify-app-in-symfony-session-tokens-token-exchange-and-the-parts-nobody-438g</guid>
      <description>&lt;p&gt;Shopify's app documentation has exactly one first-class path: Node, the&lt;br&gt;
official CLI, and a Remix template that wires authentication for you.&lt;br&gt;
Pick any other language and you leave the paved road at the first turn,&lt;br&gt;
because everything interesting happens &lt;em&gt;before&lt;/em&gt; your framework's router&lt;br&gt;
sees the request: the app runs in an iframe inside the Shopify admin, the&lt;br&gt;
browser will not give you a cookie there, and the token you need to call&lt;br&gt;
the Admin API has to be traded for on the fly.&lt;/p&gt;

&lt;p&gt;None of that is hard. It is just undocumented outside JavaScript. This&lt;br&gt;
post is the complete authentication and security path for an embedded&lt;br&gt;
Shopify app written in &lt;strong&gt;Symfony 7.4 on PHP 8.5&lt;/strong&gt;, taken from&lt;br&gt;
&lt;a href="https://stockanvil.shipanvil.com/" rel="noopener noreferrer"&gt;StockAnvil&lt;/a&gt;, an app that passed&lt;br&gt;
Shopify's App Store review and is live today. Every snippet below is&lt;br&gt;
production code, not a sketch.&lt;/p&gt;
&lt;h2&gt;
  
  
  The one architectural decision everything else follows from
&lt;/h2&gt;

&lt;p&gt;An embedded app is a page inside &lt;code&gt;admin.shopify.com&lt;/code&gt;, in a cross-origin&lt;br&gt;
iframe. Third-party cookies are dead in that context, so &lt;strong&gt;a PHP session&lt;br&gt;
is not available to you&lt;/strong&gt; and never will be. Shopify's answer is a&lt;br&gt;
short-lived JWT called a &lt;em&gt;session token&lt;/em&gt;: App Bridge (their JS shim,&lt;br&gt;
served from Shopify's CDN) mints one per request, valid for one minute,&lt;br&gt;
and attaches it to every same-origin &lt;code&gt;fetch()&lt;/code&gt; your page makes.&lt;/p&gt;

&lt;p&gt;Which forces a split most PHP developers do not make by default:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The HTML shell is public and carries no shop data.&lt;/strong&gt; It is a layout,
a nav, and empty containers. Serving it requires no authentication,
sets no cookie, and reveals nothing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Every byte of merchant data is behind &lt;code&gt;/api&lt;/code&gt;, authenticated by the
session token on each call.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That split is worth stating out loud because it inverts the usual&lt;br&gt;
Symfony instinct (secure the controller, render the data server-side).&lt;br&gt;
Here, rendering data into the shell would mean authenticating a page&lt;br&gt;
load that has no credentials to authenticate with. The payoff is that&lt;br&gt;
the shell is trivially cacheable and the security surface is one&lt;br&gt;
firewall over one path prefix.&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/security.yaml&lt;/span&gt;
&lt;span class="na"&gt;firewalls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Webhooks authenticate via HMAC signature in the controller, not a firewall.&lt;/span&gt;
    &lt;span class="na"&gt;webhooks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;^/webhooks&lt;/span&gt;
        &lt;span class="na"&gt;security&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="c1"&gt;# Embedded admin API: App Bridge session token (JWT) on every request.&lt;/span&gt;
    &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;^/api&lt;/span&gt;
        &lt;span class="na"&gt;stateless&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;users_in_memory&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\SessionTokenAuthenticator&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;users_in_memory&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;^/api&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;ROLE_SHOP&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;stateless: true&lt;/code&gt; is not decoration. It tells Symfony not to try to&lt;br&gt;
store the token in a session it does not have.&lt;/p&gt;
&lt;h2&gt;
  
  
  Verifying a session token: five checks, not one
&lt;/h2&gt;

&lt;p&gt;The session token is a JWT signed HS256 with your app's client secret.&lt;br&gt;
Verifying the signature is the part everyone does. The four claim checks&lt;br&gt;
after it are the part that gets skipped, and each one closes a real hole:&lt;br&gt;
without &lt;code&gt;aud&lt;/code&gt; you accept tokens minted for a &lt;em&gt;different app&lt;/em&gt; that happens&lt;br&gt;
to share nothing but the algorithm; without the &lt;code&gt;iss&lt;/code&gt;/&lt;code&gt;dest&lt;/code&gt; match you&lt;br&gt;
accept a token that claims one shop in one place and another shop&lt;br&gt;
elsewhere.&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/Shopify/SessionTokenVerifier.php&lt;/span&gt;
&lt;span class="nv"&gt;$expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash_hmac&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;$encodedHeader&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'.'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$encodedPayload&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;apiSecret&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="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;$signature&lt;/span&gt; &lt;span class="o"&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="nv"&gt;$signature&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;InvalidSessionTokenException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Invalid JWT signature.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$now&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;clock&lt;/span&gt;&lt;span class="o"&gt;-&amp;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;getTimestamp&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// exp / nbf, with a small leeway for clock skew&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="nf"&gt;\is_int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$exp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$now&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;LEEWAY_SECONDS&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nv"&gt;$exp&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;InvalidSessionTokenException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Session token has expired.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// aud must be *our* client id&lt;/span&gt;
&lt;span class="nv"&gt;$audiences&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;\is_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$aud&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$aud&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$aud&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="nf"&gt;\in_array&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;apiKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$audiences&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="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;InvalidSessionTokenException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Session token audience mismatch.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// dest is the shop; iss must live on the same host&lt;/span&gt;
&lt;span class="nv"&gt;$shopDomain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;parse_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$dest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;\PHP_URL_HOST&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="nf"&gt;\is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$shopDomain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nc"&gt;ShopDomain&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;isValid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$shopDomain&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;InvalidSessionTokenException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'dest claim is not a valid shop domain.'&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="nb"&gt;parse_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$iss&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;\PHP_URL_HOST&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$shopDomain&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;InvalidSessionTokenException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'iss and dest claims do not match.'&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;Two details that cost time if you get them wrong. Use &lt;code&gt;hash_equals&lt;/code&gt;, not&lt;br&gt;
&lt;code&gt;===&lt;/code&gt;, on the signature: this is the textbook timing-attack surface and&lt;br&gt;
it costs one function name. And take the clock from&lt;br&gt;
&lt;code&gt;Symfony\Component\Clock\ClockInterface&lt;/code&gt; rather than calling &lt;code&gt;time()&lt;/code&gt;,&lt;br&gt;
so your test suite can produce an expired token without sleeping.&lt;/p&gt;

&lt;p&gt;A five-second leeway on &lt;code&gt;exp&lt;/code&gt; and &lt;code&gt;nbf&lt;/code&gt; is deliberate. The token lives&lt;br&gt;
sixty seconds; a server clock a couple of seconds behind Shopify's&lt;br&gt;
would otherwise reject perfectly good tokens at a rate that looks like a&lt;br&gt;
random, unreproducible bug.&lt;/p&gt;
&lt;h2&gt;
  
  
  Token exchange, and the &lt;code&gt;expiring=1&lt;/code&gt; that returns 403 without it
&lt;/h2&gt;

&lt;p&gt;A session token proves &lt;em&gt;who is asking&lt;/em&gt;. It does not let you call the&lt;br&gt;
Admin API. For that you trade it for an access token, using OAuth 2.0&lt;br&gt;
token exchange (RFC 8693) rather than the old authorization-code dance&lt;br&gt;
with redirects. With managed installation, this is the entire install&lt;br&gt;
flow: no &lt;code&gt;/auth&lt;/code&gt; route, no redirect, no callback. The first authenticated&lt;br&gt;
request from a new shop simply performs the exchange.&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/Shopify/TokenExchanger.php&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;GRANT_TYPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'urn:ietf:params:oauth:grant-type:token-exchange'&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;SUBJECT_TOKEN_TYPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'urn:ietf:params:oauth:token-type:id_token'&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;OFFLINE_TOKEN_TYPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'urn:shopify:params:oauth:token-type:offline-access-token'&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;exchangeForOfflineToken&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;$shopDomain&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;$sessionToken&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;AccessToken&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;requestToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$shopDomain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'grant_type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;GRANT_TYPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'subject_token'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$sessionToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'subject_token_type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;SUBJECT_TOKEN_TYPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'requested_token_type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;OFFLINE_TOKEN_TYPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'expiring'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line is the one to copy. &lt;strong&gt;Ask for a non-expiring offline&lt;br&gt;
token and the Admin API answers 403&lt;/strong&gt; (observed live on 2 July 2026),&lt;br&gt;
with an error that does not say so. An expiring token lives about an&lt;br&gt;
hour and is renewed with a refresh token, which Shopify &lt;strong&gt;rotates on&lt;br&gt;
every call&lt;/strong&gt;: whatever persists your tokens has to write the new refresh&lt;br&gt;
token back, or your background jobs work for an hour and then stop.&lt;/p&gt;

&lt;p&gt;Offline (not online) is the right request for anything that runs without&lt;br&gt;
a merchant present: webhook handlers, nightly digests, sync jobs.&lt;/p&gt;
&lt;h2&gt;
  
  
  The authenticator, and the header that saves your error rate
&lt;/h2&gt;

&lt;p&gt;Symfony's custom authenticator maps onto this cleanly. The passport is&lt;br&gt;
self-validating because the JWT signature &lt;em&gt;is&lt;/em&gt; the credential check.&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/SessionTokenAuthenticator.php&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="nf"&gt;str_starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;headers&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;'Authorization'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'Bearer '&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="nv"&gt;$jwt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;headers&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;'Authorization'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;\strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Bearer '&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$sessionToken&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;sessionTokenVerifier&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;$jwt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$shop&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;shopInstaller&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ensureInstalled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sessionToken&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;InvalidSessionTokenException&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nc"&gt;TokenExchangeFailedException&lt;/span&gt; &lt;span class="nv"&gt;$e&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;CustomUserMessageAuthenticationException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMessage&lt;/span&gt;&lt;span class="p"&gt;(),&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="nv"&gt;$e&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SelfValidatingPassport&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;$sessionToken&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;shopDomain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;ShopUser&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ShopUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$shop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$sessionToken&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;subject&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;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;unauthorized&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;JsonResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="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="s1"&gt;'Unauthorized.'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;HTTP_UNAUTHORIZED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'X-Shopify-Retry-Invalid-Session-Request'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'1'&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;X-Shopify-Retry-Invalid-Session-Request&lt;/code&gt; is worth a paragraph of its&lt;br&gt;
own. Tokens last sixty seconds, so a page left open on a merchant's&lt;br&gt;
second monitor will eventually fire a request with a token that expired&lt;br&gt;
while they were in another tab. Return a bare 401 and the merchant sees&lt;br&gt;
an error. Return 401 &lt;em&gt;with that header&lt;/em&gt; and App Bridge silently fetches&lt;br&gt;
a fresh token and retries once. One header turns a class of user-visible&lt;br&gt;
failures into nothing at all.&lt;/p&gt;

&lt;p&gt;Note also where installation happens: &lt;code&gt;ensureInstalled()&lt;/code&gt; inside&lt;br&gt;
&lt;code&gt;authenticate()&lt;/code&gt;. A shop that appears with a valid token and no row in&lt;br&gt;
your database is a new install, and the token exchange happens right&lt;br&gt;
there. There is no separate install endpoint to secure.&lt;/p&gt;
&lt;h2&gt;
  
  
  Webhooks: raw body, constant time, and the query HMAC that is not &lt;code&gt;http_build_query&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Webhooks carry no session token. They are signed: HMAC-SHA256 over the&lt;br&gt;
&lt;strong&gt;raw request body&lt;/strong&gt;, base64-encoded in &lt;code&gt;X-Shopify-Hmac-Sha256&lt;/code&gt;. Raw&lt;br&gt;
means raw, before any JSON decoding, before any middleware touches it.&lt;br&gt;
Shopify's automated App Store review sends a deliberately mis-signed&lt;br&gt;
webhook and requires a 401.&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;verifyWebhook&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;$rawBody&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;$hmacHeader&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$hmacHeader&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;$expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;base64_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;hash_hmac&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;$rawBody&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;apiSecret&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="k"&gt;return&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="nv"&gt;$hmacHeader&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 second signature type is the sharp one. Links from the Shopify admin&lt;br&gt;
carry an &lt;code&gt;hmac&lt;/code&gt; query parameter computed over the sorted query string,&lt;br&gt;
and the message Shopify signs is &lt;strong&gt;not&lt;/strong&gt; a URL-encoded query string. Only&lt;br&gt;
&lt;code&gt;&amp;amp;&lt;/code&gt;, &lt;code&gt;%&lt;/code&gt; and &lt;code&gt;=&lt;/code&gt; are escaped, and only in the places shown below.&lt;br&gt;
Reaching for &lt;code&gt;http_build_query()&lt;/code&gt; here produces a signature that is&lt;br&gt;
wrong for any value containing a space or a slash, which is exactly the&lt;br&gt;
kind of bug that passes every test you thought to write:&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;$pairs&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;$query&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$pairs&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;strtr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&amp;amp;'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'%26'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'%25'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'='&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'%3D'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'='&lt;/span&gt;
        &lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;strtr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&amp;amp;'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'%26'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'%25'&lt;/span&gt;&lt;span class="p"&gt;]);&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="nb"&gt;hash_hmac&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="nb"&gt;implode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'&amp;amp;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$pairs&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;apiSecret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Two things the App Store checks that are pure infrastructure
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Access tokens encrypted at rest.&lt;/strong&gt; A shop's access token is a&lt;br&gt;
credential for someone else's business. Storing it in plaintext means a&lt;br&gt;
read-only SQL injection anywhere in your app hands over every merchant's&lt;br&gt;
store. Libsodium makes this eight lines, and PHP ships 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="c1"&gt;// src/Shopify/TokenCipher.php — XSalsa20-Poly1305 secretbox&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;encrypt&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;$plaintext&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="nv"&gt;$nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;random_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;\SODIUM_CRYPTO_SECRETBOX_NONCEBYTES&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;base64_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$nonce&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;sodium_crypto_secretbox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$plaintext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$nonce&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;key&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 key is 32 random bytes, base64-encoded, in an environment variable,&lt;br&gt;
and the constructor rejects anything else at boot rather than at the&lt;br&gt;
first decrypt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A frame-ancestors CSP scoped to the current shop.&lt;/strong&gt; Your app must be&lt;br&gt;
frameable by the merchant's admin and by nobody else, which means the&lt;br&gt;
header is computed per request from the &lt;code&gt;shop&lt;/code&gt; parameter, not set once&lt;br&gt;
in the vhost:&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/EventListener/EmbeddedAppHeadersListener.php&lt;/span&gt;
&lt;span class="nv"&gt;$frameAncestors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ShopDomain&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;isValid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$shop&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;\sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'frame-ancestors https://%s https://admin.shopify.com;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$shop&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"frame-ancestors 'none';"&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;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="n"&gt;headers&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;'Content-Security-Policy'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$frameAncestors&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deny-all on the fallback is the correct default: a request without a&lt;br&gt;
valid shop is not an admin iframe, and it should not be framed at all.&lt;br&gt;
That request is also your public landing page: the app URL registered in&lt;br&gt;
the Partner Dashboard is the address a crawler or a curious merchant&lt;br&gt;
reaches from a link outside the admin, and serving them the App Bridge&lt;br&gt;
shell shows an inert blank page. Branch on the &lt;code&gt;shop&lt;/code&gt; parameter and&lt;br&gt;
render marketing HTML instead.&lt;/p&gt;

&lt;p&gt;One more App Store requirement that is easy to miss in a Twig layout:&lt;br&gt;
&lt;strong&gt;App Bridge must be the first script in &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, loaded synchronously&lt;br&gt;
from Shopify's CDN&lt;/strong&gt;, never bundled, never deferred, on &lt;em&gt;every&lt;/em&gt; embedded&lt;br&gt;
page.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Symfony bug that this app found, which has nothing to do with Shopify
&lt;/h2&gt;

&lt;p&gt;Worth knowing whoever you build for. This email subject line lost its&lt;br&gt;
first two words in production, silently:&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;digest.attachment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Attached:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;your&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;restock&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;list&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(%count%&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;items).'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It rendered as "your restock list (12 items)." because&lt;br&gt;
&lt;code&gt;%count%&lt;/code&gt; is numeric, which routes the string through Symfony's&lt;br&gt;
pluralization logic. There, in&lt;br&gt;
&lt;code&gt;symfony/translation-contracts/TranslatorTrait.php&lt;/code&gt;, each part is tested&lt;br&gt;
against &lt;code&gt;'/^\w+\:\s*(.*?)$/'&lt;/code&gt; — an explicit-interval syntax for keyed&lt;br&gt;
plural rules — and a message that innocently begins with a word followed&lt;br&gt;
by a colon matches. The prefix is consumed as if it were a rule name. No&lt;br&gt;
exception, no log line, just a shorter sentence.&lt;/p&gt;

&lt;p&gt;Two lessons, one specific and one general. Specific: with &lt;code&gt;%count%&lt;/code&gt; in a&lt;br&gt;
message, never start it with &lt;code&gt;Word:&lt;/code&gt;. General, and the more expensive&lt;br&gt;
one, is why the test suite missed it. The assertion checked&lt;br&gt;
&lt;code&gt;assertStringContainsString('restock list', $subject)&lt;/code&gt; — it started&lt;br&gt;
matching in the middle of the sentence, so it could only ever have&lt;br&gt;
verified the part that never breaks. &lt;strong&gt;Assert a string from its first&lt;br&gt;
character.&lt;/strong&gt; The fix now also walks both translation catalogues and fails&lt;br&gt;
CI on any &lt;code&gt;%count%&lt;/code&gt; message matching that pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this adds up to
&lt;/h2&gt;

&lt;p&gt;An embedded Shopify app in Symfony is roughly 400 lines of security code&lt;br&gt;
you cannot copy from the docs: a JWT verifier, a token exchanger with&lt;br&gt;
refresh-token rotation, a stateless authenticator, an HMAC verifier with&lt;br&gt;
two algorithms, an encryption wrapper, and a response listener. After&lt;br&gt;
that, it is a Symfony app like any other, with Doctrine, Messenger for&lt;br&gt;
the background sync, and the same testing tools you already use. The&lt;br&gt;
platform-specific surface is small and it stays where you put it.&lt;/p&gt;

&lt;p&gt;The app that produced this code is &lt;a href="https://stockanvil.shipanvil.com/" rel="noopener noreferrer"&gt;StockAnvil&lt;/a&gt;,&lt;br&gt;
low-stock alerts for Shopify merchants, &lt;a href="https://apps.shopify.com/stockanvil" rel="noopener noreferrer"&gt;live in the App&lt;br&gt;
Store&lt;/a&gt;, built on&lt;br&gt;
&lt;a href="https://shipanvil.com/" rel="noopener noreferrer"&gt;ShipAnvil&lt;/a&gt; with its auth, billing, admin and deploy pipeline&lt;br&gt;
already in place. If you are weighing an inventory tool because of the&lt;br&gt;
&lt;a href="https://stockanvil.shipanvil.com/stocky-alternative" rel="noopener noreferrer"&gt;Stocky shutdown on 31 August 2026&lt;/a&gt;,&lt;br&gt;
that page is an honest map of which replacement covers which part,&lt;br&gt;
including the parts StockAnvil does not do.&lt;/p&gt;

&lt;p&gt;For the Symfony foundations under all of this, start with the&lt;br&gt;
&lt;a href="https://shipanvil.com/blog/deploy-symfony-vps" rel="noopener noreferrer"&gt;production VPS deploy&lt;/a&gt; and&lt;br&gt;
&lt;a href="https://shipanvil.com/blog/symfony-74-lts-for-saas" rel="noopener noreferrer"&gt;Symfony 7.4 LTS support math&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://shipanvil.com/blog/shopify-embedded-app-symfony" rel="noopener noreferrer"&gt;https://shipanvil.com/blog/shopify-embedded-app-symfony&lt;/a&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>symfony</category>
      <category>shopify</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A league season is not a long tournament: four product bugs my test suite could never catch</title>
      <dc:creator>Eric Mollenthiel</dc:creator>
      <pubDate>Fri, 31 Jul 2026 00:56:22 +0000</pubDate>
      <link>https://dev.to/mollenthiel/a-league-season-is-not-a-long-tournament-four-product-bugs-my-test-suite-could-never-catch-1ih4</link>
      <guid>https://dev.to/mollenthiel/a-league-season-is-not-a-long-tournament-four-product-bugs-my-test-suite-could-never-catch-1ih4</guid>
      <description>&lt;p&gt;I built a football prediction game for the 2026 World Cup. A month long, 104 matches,&lt;br&gt;
one winner at the end. It worked, people played, nothing caught fire.&lt;/p&gt;

&lt;p&gt;Then I pointed the same app at a domestic league: Ligue 1, 306 matches, 34 matchdays,&lt;br&gt;
nine months. Same data model, same scoring, same templates. Nothing crashed. Nothing&lt;br&gt;
threw. Every test stayed green.&lt;/p&gt;

&lt;p&gt;And almost every product decision inside the app was suddenly wrong.&lt;/p&gt;

&lt;p&gt;Here are the four that mattered, because none of them were technical, and none of them&lt;br&gt;
would have shown up in a test suite.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A cumulative leaderboard is decided by October
&lt;/h2&gt;

&lt;p&gt;In a tournament, the overall standings are the whole game. You play for four weeks and&lt;br&gt;
the table at the end is the story.&lt;/p&gt;

&lt;p&gt;Over 34 matchdays, that table stops being a game around week eight. The player who&lt;br&gt;
started well is 60 points ahead, the player who joined in November is mathematically out,&lt;br&gt;
and everyone else is reading a scoreboard they cannot change. The product still worked.&lt;br&gt;
It just had no stakes left.&lt;/p&gt;

&lt;p&gt;The fix was not a better algorithm, it was a second unit of time: a per-matchday&lt;br&gt;
leaderboard, so each weekend has its own winner, plus a season honours table counting&lt;br&gt;
how many matchdays each player has won. Same points, same scoring, sliced differently.&lt;br&gt;
A player who is 14th overall can still win this weekend, and that is the thing that&lt;br&gt;
makes them come back on Friday.&lt;/p&gt;

&lt;p&gt;Worth noting what I did &lt;em&gt;not&lt;/em&gt; do: no reset, no handicap, no catch-up bonus. Anything&lt;br&gt;
retroactive on a scoring system that people are currently playing destroys trust in the&lt;br&gt;
standings, and the standings are the entire asset.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A sliding 24 hour reminder becomes 100 emails
&lt;/h2&gt;

&lt;p&gt;The reminder job was built for a tournament: "if a player has unpredicted matches&lt;br&gt;
kicking off in the next 24 hours, email them." Matches trickle in daily, so that reads&lt;br&gt;
as one email a day, and it is fine.&lt;/p&gt;

&lt;p&gt;A Ligue 1 matchday runs from Friday 20:45 to Sunday 20:45. The same job, unchanged,&lt;br&gt;
would have sent &lt;strong&gt;three emails per weekend&lt;/strong&gt; to the same person: one on Friday for one&lt;br&gt;
match, one on Saturday for three, one on Sunday for five. Around a hundred emails per&lt;br&gt;
season, per player. That is not a reminder, that is a spam complaint with extra steps.&lt;/p&gt;

&lt;p&gt;It would also have arrived on the morning of the first match, when the natural gesture in&lt;br&gt;
a league is the opposite: you fill all nine games in one sitting, once, whenever you&lt;br&gt;
think of it.&lt;/p&gt;

&lt;p&gt;So the job now has two disjoint code paths in the same command. Tournaments keep the&lt;br&gt;
sliding window. Leagues get one email per matchday, fired when the &lt;strong&gt;first kickoff of&lt;br&gt;
that matchday is 24 to 48 hours out&lt;/strong&gt;, listing every still open match of the matchday.&lt;/p&gt;

&lt;p&gt;The part I like: there is no &lt;code&gt;reminder_sent&lt;/code&gt; table. Idempotency comes from the window&lt;br&gt;
itself. "The first kickoff is between 24 and 48 hours away" is true on exactly one&lt;br&gt;
calendar day, and the cron runs once a day. The one case that legitimately produces a&lt;br&gt;
second email is a postponement that drags the first kickoff back into the window, and a&lt;br&gt;
calendar that moved is exactly when you want to remind people again.&lt;/p&gt;

&lt;p&gt;The tradeoff is written in the crontab in plain words: doubling the cron frequency would&lt;br&gt;
double the emails. A comment is cheaper than a table, as long as the comment is where the&lt;br&gt;
mistake would be made.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. "Upcoming matches" is not "all future matches"
&lt;/h2&gt;

&lt;p&gt;The dashboard listed every future match. In a tournament that is at most a few dozen&lt;br&gt;
cards, and the progress badge reads "12/18 predicted", which feels achievable.&lt;/p&gt;

&lt;p&gt;In a league it is 306 cards and "12/306", which feels like homework.&lt;/p&gt;

&lt;p&gt;Now the list is bounded to the next two matchdays. The subtle part is &lt;em&gt;how&lt;/em&gt; you pick&lt;br&gt;
them. My first version took &lt;code&gt;MIN(round_number)&lt;/code&gt; over unplayed matches. That is wrong in&lt;br&gt;
any real league, because postponements are routine: one match of matchday 3 replayed in&lt;br&gt;
November would have pinned the dashboard to matchdays 3 and 4 and hidden the actual&lt;br&gt;
weekend. The window follows &lt;strong&gt;nearest kickoff times&lt;/strong&gt;, not round numbers, and the&lt;br&gt;
postponed match reappears by itself when its new slot comes around.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. In a tournament there is always something to do
&lt;/h2&gt;

&lt;p&gt;Four weeks of a World Cup is four weeks of permanent attention. Nine months is not. Most&lt;br&gt;
of a league season, an engagement product is competing with the user forgetting it&lt;br&gt;
exists.&lt;/p&gt;

&lt;p&gt;The two moments I built for are both moments the app already knew about and was throwing&lt;br&gt;
away: the player who just &lt;strong&gt;won a matchday&lt;/strong&gt; (one per week, peak pride) and the player&lt;br&gt;
who just &lt;strong&gt;finished predicting the coming matchday&lt;/strong&gt; (everyone, 34 times a season, peak&lt;br&gt;
engagement and then several days of nothing). Both now offer a share, native share sheet&lt;br&gt;
on mobile with a WhatsApp fallback, and both link to the public competition page rather&lt;br&gt;
than the private league.&lt;/p&gt;

&lt;p&gt;That last detail took a minute of thought and is worth the minute: sharing a league&lt;br&gt;
invite code from a mobile share sheet means it can land in a public post, and a private&lt;br&gt;
standings table with strangers in it is not a feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual lesson
&lt;/h2&gt;

&lt;p&gt;The data model was right. &lt;code&gt;Event&lt;/code&gt;, &lt;code&gt;Game&lt;/code&gt;, &lt;code&gt;Prediction&lt;/code&gt;, a &lt;code&gt;round_number&lt;/code&gt; column that was&lt;br&gt;
already there. Not one migration was needed for any of this.&lt;/p&gt;

&lt;p&gt;What was wrong was every assumption about &lt;strong&gt;cadence&lt;/strong&gt;: how often the user shows up, how&lt;br&gt;
long a unit of competition lasts, how far ahead they can see, how long they wait between&lt;br&gt;
two moments of interest. Those assumptions are almost never in your schema. They are&lt;br&gt;
spread across cron expressions, query limits, email conditions, and empty state copy,&lt;br&gt;
which is exactly where nobody looks when they say "we just need to support a new&lt;br&gt;
competition format".&lt;/p&gt;

&lt;p&gt;If you are about to reuse a working product on a longer or shorter timescale, grep your&lt;br&gt;
codebase for time: every &lt;code&gt;24 hours&lt;/code&gt;, every &lt;code&gt;setMaxResults&lt;/code&gt;, every "next" and "current"&lt;br&gt;
and "upcoming". That is your real diff.&lt;/p&gt;

&lt;p&gt;The app is a free prediction game for friends and coworkers, no betting and no money&lt;br&gt;
involved, built with Symfony, Turbo Streams over Mercure for live standings, and 16&lt;br&gt;
locales. It is at &lt;a href="https://pronoarena.com" rel="noopener noreferrer"&gt;pronoarena.com&lt;/a&gt; if you want to see the result,&lt;br&gt;
and the Ligue 1 season starts on August 21.&lt;/p&gt;

</description>
      <category>product</category>
      <category>softwareengineering</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
