<?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>Symfony 7.4 to 8.1, measured twice a month apart: two of the scary parts were fixed in patch releases</title>
      <dc:creator>Eric Mollenthiel</dc:creator>
      <pubDate>Thu, 01 Oct 2026 17:22:18 +0000</pubDate>
      <link>https://dev.to/mollenthiel/symfony-74-to-81-measured-twice-a-month-apart-two-of-the-scary-parts-were-fixed-in-patch-32h8</link>
      <guid>https://dev.to/mollenthiel/symfony-74-to-81-measured-twice-a-month-apart-two-of-the-scary-parts-were-fixed-in-patch-32h8</guid>
      <description>&lt;p&gt;On 26 August I measured what it costs to move a real Symfony codebase from 7.4 to 8.1: a kit with a few hundred tests, PHPStan at level max, Doctrine on PostgreSQL. Four lines of code, one failing test, zero new PHPStan errors, zero new deprecations, and one cache step that turns "the whole app returns 500" into "fine". I published those numbers in a Slack thread where a Symfony core contributor had rightly pushed back on a kit that pins the LTS, and they went into an upgrade guide.&lt;/p&gt;

&lt;p&gt;Today I ran the same upgrade again, on the same codebase, four weeks and two patch releases later. Four of those five numbers changed. Two of the changes are Symfony patch releases fixing what I had blamed on the major, and one of those two bugs was in 7.4 too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bench
&lt;/h2&gt;

&lt;p&gt;Two throwaway copies of the kit at the same commit (&lt;code&gt;git archive&lt;/code&gt;, nothing touched in the real repository), PHP 8.5.7, Composer 2.9.7, a throwaway PostgreSQL 18.3 database. One copy stays on its lock file, Symfony 7.4. The other gets the upgrade:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;every &lt;code&gt;"7.4.*"&lt;/code&gt; in &lt;code&gt;composer.json&lt;/code&gt; becomes &lt;code&gt;"8.1.*"&lt;/code&gt; (26 occurrences, &lt;code&gt;extra.symfony.require&lt;/code&gt; included);&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;require.php&lt;/code&gt; goes from &lt;code&gt;&amp;gt;=8.4&lt;/code&gt; to &lt;code&gt;&amp;gt;=8.4.1&lt;/code&gt; and &lt;code&gt;config.platform.php&lt;/code&gt; from &lt;code&gt;8.4.0&lt;/code&gt; to &lt;code&gt;8.4.1&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;composer update --with-all-dependencies&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;two method signatures widened;&lt;/li&gt;
&lt;li&gt;the test suite, PHPStan, and the deprecations, on both copies.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The whole run is one shell script, and every number below comes from its output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then and now
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                                        26 August (8.1.5)        24 September (8.1.7)
Resolution blocked by third-party       none                     none
Lines of application code to change     4, in 2 files            4, in 2 files
Failing tests                           1                        0 out of 483
Stale cache compiled under 7.4          every page 500           green
New PHPStan errors, level max           0                        2, not from Symfony
New deprecations from Symfony           0                        2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What did not move first, because it is still the part that looks worst and costs least.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The resolution fails with 22 problems, and none of them is a bundle.&lt;/strong&gt; Every one reads like a third-party conflict and is the same line: &lt;code&gt;symfony/... v8.1.0 requires php &amp;gt;=8.4.1 -&amp;gt; your php version (8.4.0; overridden via config.platform, actual: 8.5.7)&lt;/code&gt;. The server runs 8.5; the platform pin in &lt;code&gt;composer.json&lt;/code&gt; says 8.4.0, and 8.1 wants 8.4.1. Two lines, and the resolution goes through. It updates 100 packages today, installs 3 and removes 3.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The two breaking changes are fatal errors while the container is built&lt;/strong&gt;, so nothing can slip through: &lt;code&gt;Voter::voteOnAttribute()&lt;/code&gt; gains a &lt;code&gt;?Vote $vote = null&lt;/code&gt; parameter, and &lt;code&gt;UserCheckerInterface::checkPostAuth()&lt;/code&gt; receives the token. PHP names the file and the line. That is the four lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failing test was a patch regression, and it was in 7.4 too
&lt;/h2&gt;

&lt;p&gt;In August, one test failed: a controller that streams server-sent events, where the body reached the test client empty. The status was 200, the &lt;code&gt;Content-Type&lt;/code&gt; was &lt;code&gt;text/event-stream&lt;/code&gt;, and the controller does what every SSE endpoint does after each event:&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;echo&lt;/span&gt; &lt;span class="s1"&gt;'data: '&lt;/span&gt;&lt;span class="mf"&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;$payload&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="mf"&gt;.&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&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;ob_get_level&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;ob_flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The diagnosis at the time was correct as far as it went. &lt;code&gt;HttpKernelBrowser::doRequest()&lt;/code&gt; in 8.1.5 captured the body with a plain &lt;code&gt;ob_start()&lt;/code&gt; and read it back with &lt;code&gt;ob_get_clean()&lt;/code&gt;. &lt;code&gt;ob_flush()&lt;/code&gt; on a buffer without a callback pushes the content through to the parent buffer, so by the time &lt;code&gt;ob_get_clean()&lt;/code&gt; runs there is nothing left. We wrote it down as "what changed in 8.x".&lt;/p&gt;

&lt;p&gt;It was not 8.x. Today the test is green on 8.1.7, so I went looking for the version where it came back, and the answer is in &lt;code&gt;symfony/http-kernel&lt;/code&gt;'s history:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;20 August: "Send the response content before terminating the kernel in the test client". That commit introduced the plain &lt;code&gt;ob_start()&lt;/code&gt; in &lt;code&gt;doRequest()&lt;/code&gt;, on the 6.4 branch, merged up.&lt;/li&gt;
&lt;li&gt;22 August: released in &lt;strong&gt;7.4.17&lt;/strong&gt; and &lt;strong&gt;8.1.5&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;23 August: "Capture flushed content in HttpKernelBrowser", which switches both capture points to &lt;code&gt;ob_start()&lt;/code&gt; with a callback that accumulates each chunk and returns an empty string.&lt;/li&gt;
&lt;li&gt;30 August: released in 7.4.18 and 8.1.6.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The proof that it is not a major-version change is to leave the 7.4 copy on 7.4 and move one package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Symfony 7.4, http-kernel 7.4.13 (the kit's lock)   ChatTest  OK (7 tests)
Symfony 7.4, http-kernel 7.4.17                    ChatTest  1 failure, the same test
Symfony 7.4, http-kernel 7.4.18                    ChatTest  OK (7 tests)
Symfony 8.1, http-kernel 8.1.5                     ChatTest  1 failure
Symfony 8.1, http-kernel 8.1.7                     ChatTest  OK (7 tests)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mistake in August was a comparison with two variables. The 7.4 reference ran on an &lt;code&gt;http-kernel&lt;/code&gt; locked since May; the 8.1 branch had just resolved to a patch release that was four days old. Any project that ran &lt;code&gt;composer update&lt;/code&gt; on 7.4 between 22 and 30 August and streams a response in a functional test saw the same failure, with no upgrade at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 500s were a removed class, and 8.1.6 put a stub back
&lt;/h2&gt;

&lt;p&gt;The other finding from August was that after the upgrade, every page returned a 500 on &lt;code&gt;Class "Symfony\Component\VarExporter\Internal\Hydrator" not found&lt;/code&gt; until the cache directory was deleted. Symfony 8.1 had removed that internal class, and code exported by &lt;code&gt;symfony/var-exporter&lt;/code&gt; under 7.4 (cache pools, in this case) still called it.&lt;/p&gt;

&lt;p&gt;Replayed today with the test cache compiled under 7.4 copied into the 8.1 copy, and no &lt;code&gt;cache:clear&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;var-exporter 8.1.5   483 tests, 38 errors, 65 failures, 530 lines "Internal\Hydrator not found"
var-exporter 8.1.6   OK (483 tests)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On 29 August, &lt;code&gt;symfony/var-exporter&lt;/code&gt; 8.1.6 added the class back as a stub whose only method throws a &lt;code&gt;LogicException&lt;/code&gt;: "Code exported by symfony/var-exporter &amp;lt; 8.1 cannot be loaded anymore and must be regenerated." A cache pool treats the exception as a miss and regenerates the entry, where a missing class was a fatal error. Deleting &lt;code&gt;var/cache&lt;/code&gt; after changing a major version is still the right habit. It is no longer the difference between a working app and a broken one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deprecations: the count depends on whether the container is compiled in that run
&lt;/h2&gt;

&lt;p&gt;In August I wrote that Symfony 8 adds no deprecation to this codebase and removes one. The second half holds: the 7.4 deprecation about &lt;code&gt;User::eraseCredentials()&lt;/code&gt; is gone on 8.1. The first half does not. Same suite, &lt;code&gt;ignoreIndirectDeprecations&lt;/code&gt; off, run twice in a row, first with &lt;code&gt;var/cache/test&lt;/code&gt; deleted, then with the container already compiled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                         cold container    warm container
Symfony 7.4 (reference)        5                  3
Symfony 8.1.7                 19                 13
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two deprecations come from Symfony 8.1 and exist since 8.1.0, and both only show up in the cold run, because they are raised while the container is compiled:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Setting the "framework.profiler.collect_serializer_data" configuration option is deprecated. It will be removed in version 9.0.&lt;/code&gt; The kit sets it in &lt;code&gt;web_profiler.yaml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Relying solely on the name of parameter "$anthropicClient" of "__construct()" to match a named autowiring alias is deprecated; use the "#[Target]" attribute.&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A run on a warm container reports neither. I cannot reconstruct the state of the cache during the August run, so I will not claim that is what happened then, but it is the simplest explanation for a zero. If you count deprecations to size an upgrade, delete &lt;code&gt;var/cache/test&lt;/code&gt; first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;--with-all-dependencies&lt;/code&gt; brings that is not Symfony
&lt;/h2&gt;

&lt;p&gt;The rest of the new deprecations and both PHPStan errors come from packages that moved because &lt;code&gt;-W&lt;/code&gt; let them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;twig/twig&lt;/code&gt; 3.29 deprecates calling a macro without a value for an argument that has no default (10 occurrences, one macro).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;doctrine/orm&lt;/code&gt; deprecates passing a string as the direction to &lt;code&gt;QueryBuilder::orderBy()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;horstoeko/zugferd&lt;/code&gt; went from 1.0.123 to 1.0.132, and a tighter PHPDoc return type makes PHPStan flag an &lt;code&gt;is_string()&lt;/code&gt; and an &lt;code&gt;instanceof&lt;/code&gt; in a test as always true and always false. Pin it back to 1.0.123 on the 8.1 copy and PHPStan is at zero errors again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is the cost of Symfony 8.1, and all of it lands in the same pull request. When you size the upgrade, count the two separately, or a Twig macro and an e-invoicing library end up in the bill for Symfony 8.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not measure
&lt;/h2&gt;

&lt;p&gt;It measures the kit as it ships, not an application written on top of it: your own code has its own API surface. The SSE test passing again proves the test client captures the stream; it is still not a run in a real browser under 8.1. And one reproduction of the August state is missing: in my replay, &lt;code&gt;cache:clear&lt;/code&gt; on the stale cache was enough to clean it, where the guide says it is not, so the only claim I keep is the one I could replay, delete the directory.&lt;/p&gt;

&lt;p&gt;The codebase is &lt;a href="https://shipanvil.com/pricing?ref=devto-10" rel="noopener noreferrer"&gt;ShipAnvil&lt;/a&gt;, a multi-tenant Symfony kit that stays pinned to 7.4 LTS: here is what the source code licence contains and costs. The upgrade guide it ships was written from the August run, and as of today three of its lines are wrong in the ways described above.&lt;/p&gt;

&lt;p&gt;The general lesson is smaller than the numbers: when a major upgrade breaks something, install the same patch release of the failing component on the old branch before you blame the major.&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>php</category>
      <category>testing</category>
      <category>upgrade</category>
    </item>
    <item>
      <title>Postgres RLS in Symfony: a setup check for a missing FORCE, and two ways it says "nothing to report" without looking</title>
      <dc:creator>Eric Mollenthiel</dc:creator>
      <pubDate>Thu, 24 Sep 2026 15:07:23 +0000</pubDate>
      <link>https://dev.to/mollenthiel/postgres-rls-in-symfony-a-setup-check-for-a-missing-force-and-two-ways-it-says-nothing-to-279l</link>
      <guid>https://dev.to/mollenthiel/postgres-rls-in-symfony-a-setup-check-for-a-missing-force-and-two-ways-it-says-nothing-to-279l</guid>
      <description>&lt;p&gt;Three articles ago I wrote that the Postgres role which runs your migrations bypasses every row-level security policy, unless the table has &lt;code&gt;FORCE ROW LEVEL SECURITY&lt;/code&gt;. In the thread under the article before that one, &lt;a class="mentioned-user" href="https://dev.to/to21as"&gt;@to21as&lt;/a&gt; made a proposal I had no argument against: a setup check does not have to impose &lt;code&gt;FORCE&lt;/code&gt; or a separate role to be useful. It can just detect that the role serving the requests owns the tenant tables and that &lt;code&gt;FORCE&lt;/code&gt; is off, and say so. Nothing imposed, and the buyer who deploys with &lt;code&gt;CREATE DATABASE app_db OWNER app&lt;/code&gt; finds out at setup instead of never.&lt;/p&gt;

&lt;p&gt;I agreed, and then I wrote the one thing I could not answer yet: what do those queries return for a serving role that is not the owner and cannot see what it does not own? A check that answers "nothing to report" because it cannot see the tables would be worse than no check at all.&lt;/p&gt;

&lt;p&gt;I have the number now. The short answer is reassuring: the catalog does not have the hole I was afraid of. The long answer is that the check can still say "nothing to report" without looking, by two other routes, and one of them no query can fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bench
&lt;/h2&gt;

&lt;p&gt;PostgreSQL 18.3, a throwaway database, three roles. Three tenant tables, all created by the owner, which is what &lt;code&gt;CREATE DATABASE app_db OWNER app&lt;/code&gt; followed by &lt;code&gt;doctrine:migrations:migrate&lt;/code&gt; gives you. RLS enabled on all three, a tenant policy on each, and &lt;code&gt;FORCE&lt;/code&gt; deliberately absent: that is the configuration the check is meant to catch. One of the tables, &lt;code&gt;archive.audit_log&lt;/code&gt;, lives in a schema outside the default &lt;code&gt;search_path&lt;/code&gt;, to test a second way a check can come back empty.&lt;/p&gt;

&lt;p&gt;The three roles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;det_owner&lt;/code&gt; serves the requests and owns the tables. The case to detect.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;det_app&lt;/code&gt; serves the requests, owns nothing, has normal read and write grants.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;det_bare&lt;/code&gt; serves the requests, owns nothing, and has no grant at all on the tables. A role that was never fully provisioned.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The two queries
&lt;/h2&gt;

&lt;p&gt;The check needs one thing from the application: the list of tenant tables. In a Symfony app that list already exists, in the Doctrine metadata of the entities you scope by tenant. The queries join that list to &lt;code&gt;pg_catalog&lt;/code&gt; &lt;strong&gt;by name&lt;/strong&gt;, with a &lt;code&gt;LEFT JOIN&lt;/code&gt;:&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;-- Does the serving role own the tenant tables?&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;s&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="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;                          &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;tenant_table&lt;/span&gt;&lt;span class="p"&gt;,&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;oid&lt;/span&gt; &lt;span class="k"&gt;IS&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;AS&lt;/span&gt; &lt;span class="n"&gt;seen_in_catalog&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_get_userbyid&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;relowner&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_get_userbyid&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;relowner&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;current_user&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;owned_by_me&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'organization'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
               &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'public'&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;'archive'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'audit_log'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt;   &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_catalog&lt;/span&gt;&lt;span class="p"&gt;.&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;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt;   &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_catalog&lt;/span&gt;&lt;span class="p"&gt;.&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;ON&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="o"&gt;=&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="k"&gt;AND&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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Is RLS enabled, and is it forced?&lt;/span&gt;
&lt;span class="c1"&gt;-- same FROM and joins, selecting c.relrowsecurity and c.relforcerowsecurity&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;LEFT JOIN&lt;/code&gt; is not cosmetic. It turns "I cannot see this table" into a row with &lt;code&gt;seen_in_catalog = false&lt;/code&gt;, which is a result you can act on, instead of a missing row, which is silence.&lt;/p&gt;

&lt;h2&gt;
  
  
  What they return
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                                        join by name        to_regclass()                 information_schema.tables
det_owner  owns the tables              3 rows, owned=t     3 rows                        3 rows
det_app    owns nothing, DML grants     3 rows, owned=f     3 rows                        3 rows
det_bare   owns nothing, no grants      3 rows, exact       ERROR: permission denied      0 rows, no error
                                                            for schema archive
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first column is the answer to the question from the thread. &lt;code&gt;pg_class&lt;/code&gt; and &lt;code&gt;pg_namespace&lt;/code&gt; are readable by &lt;code&gt;PUBLIC&lt;/code&gt; by default, so a role with no privilege at all on the tenant tables still reads their owner, &lt;code&gt;relrowsecurity&lt;/code&gt; and &lt;code&gt;relforcerowsecurity&lt;/code&gt;, exactly. Visibility of the catalog is not the problem.&lt;/p&gt;

&lt;p&gt;The verdict, written as one query over the same join, has three outcomes and never two:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;det_owner   WARNING: the serving role owns 3 tenant table(s) without FORCE
det_app     NOTHING TO REPORT
det_bare    NOTHING TO REPORT      (correct: it owns nothing, and it can see that)
fresh db    UNDETERMINED: 3 tenant table(s) not found in the catalog
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third outcome is the one that answers the original fear. "I could not find the tables" is not "nothing to report", and a fresh database where the migrations have not run yet gets &lt;code&gt;UNDETERMINED&lt;/code&gt;, which is the true answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Route 1: the first query you would naturally write
&lt;/h2&gt;

&lt;p&gt;The obvious way to list tables is &lt;code&gt;information_schema.tables&lt;/code&gt;. For &lt;code&gt;det_bare&lt;/code&gt; it returns zero rows, with no error. Its views only show what the current role has some privilege on.&lt;/p&gt;

&lt;p&gt;In this particular case the verdict happens to be right, since &lt;code&gt;det_bare&lt;/code&gt; owns nothing. That is exactly why it survives review. A check built on it cannot tell "there are no tenant tables here" from "I am not allowed to see them" from "the migrations never ran", so it loses the third outcome, and the third outcome was the point.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;to_regclass()&lt;/code&gt; is the other tempting shortcut, and it fails the other way. Resolving a qualified name needs &lt;code&gt;USAGE&lt;/code&gt; on the schema, so for &lt;code&gt;archive.audit_log&lt;/code&gt; the whole query dies with &lt;code&gt;permission denied for schema archive&lt;/code&gt;, taking down the answers for the two tables it could have read. That one is at least loud. The join by name does no resolution, and has neither problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  A detour: the check that warns every install
&lt;/h2&gt;

&lt;p&gt;Before the second route, a mistake I nearly made in the verdict. The condition "the serving role owns the table and &lt;code&gt;FORCE&lt;/code&gt; is off" is true on every database where RLS was never enabled. I dropped RLS from the three tables and ran it as the owner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;naive    (owned AND NOT forced)             WARNING: the serving role owns 3 tenant table(s) without FORCE
fixed    (owned AND enabled AND NOT forced) NOTHING TO REPORT: no RLS on these tables
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A project that does not use RLS at all would get a warning about a feature nobody asked for, on every run. A check that warns everyone warns no one. It should only speak when RLS is enabled and not forced.&lt;/p&gt;

&lt;h2&gt;
  
  
  Route 2: the connection, not the query
&lt;/h2&gt;

&lt;p&gt;Now the one I cannot close. Open the connection as the owner, then step down to the serving role with &lt;code&gt;SET ROLE&lt;/code&gt;, which is one way to get a non-owner role without a second set of credentials:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SET ROLE det_app;
current_user = det_app, session_user = det_owner
verdict: NOTHING TO REPORT

RESET ROLE;
current_user = det_owner
rows read from invoice with no tenant context set: 2 of 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The check asks about &lt;code&gt;current_user&lt;/code&gt;, and &lt;code&gt;current_user&lt;/code&gt; is the serving role, which owns nothing. The answer is accurate and meaningless: anything running on that connection can &lt;code&gt;RESET ROLE&lt;/code&gt; and is back to being the owner, and the owner bypasses a policy that is not forced. Two rows out of two, no context set.&lt;/p&gt;

&lt;p&gt;Asking about &lt;code&gt;session_user&lt;/code&gt; as well does catch it, all three tables come back as owned by the login role. So the check should look at both. But that only reduces the problem, it does not remove it: the check measures the connection it is run on. If you run it locally with one &lt;code&gt;DATABASE_URL&lt;/code&gt; and deploy with another, it measured the wrong role, and nothing in its output tells you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the check can live
&lt;/h2&gt;

&lt;p&gt;This bench also settled where such a check cannot go:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not before the migrations.&lt;/strong&gt; On a fresh database it returns &lt;code&gt;UNDETERMINED&lt;/code&gt;. Correct, and useless at that moment. It belongs after &lt;code&gt;doctrine:migrations:migrate&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not in a pre-install check that runs before the environment is configured.&lt;/strong&gt; It needs a real connection with the production role.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not without the application's list of tenant tables.&lt;/strong&gt; Without it there is no &lt;code&gt;LEFT JOIN&lt;/code&gt;, so no third outcome.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this does not cover
&lt;/h2&gt;

&lt;p&gt;Nothing was measured behind PgBouncer, or with a &lt;code&gt;SET ROLE&lt;/code&gt; issued by a pooler rather than by the application. I also revoked &lt;code&gt;SELECT&lt;/code&gt; on &lt;code&gt;pg_class&lt;/code&gt; and &lt;code&gt;pg_namespace&lt;/code&gt; from &lt;code&gt;PUBLIC&lt;/code&gt; to test the edge: the check then fails with a permission error, which is the right way to fail. &lt;code&gt;pg_tables&lt;/code&gt; still answers in that case, since a view runs with its owner's rights, but it does not expose &lt;code&gt;FORCE&lt;/code&gt;, so it can answer the first question and not the second. I have not seen those grants revoked in production; the case is there to find the boundary, not to describe a real deployment.&lt;/p&gt;

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

&lt;p&gt;The bench is one bash script, and every query that matters is in this article. It creates the database and the three roles, runs every query above as each role, locks the catalog, computes the verdicts, does the step-down, disables RLS for the false positive, drops the tables for the fresh database case, and removes everything on the way out. Its labels are in French: the outputs above are translated, the values are not. I wrote it alongside &lt;a href="https://shipanvil.com/pricing?ref=devto-09" rel="noopener noreferrer"&gt;ShipAnvil&lt;/a&gt;, a multi-tenant Symfony kit that scopes tenants with a Doctrine filter: here is what the source code licence contains and costs. To be clear about what that means here: the kit ships no RLS and no such check today. This is a measurement of the idea, not a feature announcement.&lt;/p&gt;

&lt;p&gt;Thanks to &lt;a class="mentioned-user" href="https://dev.to/to21as"&gt;@to21as&lt;/a&gt; for the distinction that started it: detecting is not imposing. It holds. The measurement just adds the condition: a check like this has to say what it could not see before it says anything.&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>php</category>
      <category>postgres</category>
      <category>database</category>
    </item>
    <item>
      <title>Postgres RLS in Symfony: three green isolation tests, and the UPDATE that moves tenant 1's invoices into tenant 2's books</title>
      <dc:creator>Eric Mollenthiel</dc:creator>
      <pubDate>Mon, 14 Sep 2026 15:06:17 +0000</pubDate>
      <link>https://dev.to/mollenthiel/postgres-rls-in-symfony-three-green-isolation-tests-and-the-update-that-moves-tenant-1s-invoices-4bjh</link>
      <guid>https://dev.to/mollenthiel/postgres-rls-in-symfony-three-green-isolation-tests-and-the-update-that-moves-tenant-1s-invoices-4bjh</guid>
      <description>&lt;p&gt;Two articles ago I wrote that the Postgres role which runs your migrations bypasses every row-level security policy. Last week I wrote that with one tenant in the fixture, your isolation suite passes with no policy at all. Both came out of the same comment thread, and by the end of it we had three assertions that, together, cover every broken database I had been able to build.&lt;/p&gt;

&lt;p&gt;Then Marco pointed out what he liked about the pair: it never leaves the enforcement boundary it is checking. So I went looking for the next place where a green suite means nothing, and it took one line of SQL to find it. All three assertions only read. None of them says a word about what a tenant can write.&lt;/p&gt;

&lt;p&gt;Here is a database where tenant 1 can move every one of its invoices into tenant 2's books, in one statement, and every isolation test in this series is green on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loud error that gets you there
&lt;/h2&gt;

&lt;p&gt;You do not arrive at that database on purpose. You arrive at it by fixing an error.&lt;/p&gt;

&lt;p&gt;Once a table has &lt;code&gt;FORCE ROW LEVEL SECURITY&lt;/code&gt;, the owner is subject to the policy too. That is the correct setting, it is the whole point of the first article, and it has a consequence the first time you seed a fixture: the seeding connection is the owner, the policy reads &lt;code&gt;current_setting('app.tenant_id')&lt;/code&gt;, nothing has set it, and Postgres refuses to insert.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;no context, INSERT (1,1,100)   : ERROR:  new row violates row-level security policy for table "invoice"
context=1,  INSERT (1,1,100)   : ok
context=1,  INSERT (2,2,200)   : ERROR:  new row violates row-level security policy for table "invoice"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even with the context set, the second tenant's row is refused, because you are inserting tenant 2's row while claiming to be tenant 1. My own bench hit this while I was writing the article: the seed step ran as the owner, under &lt;code&gt;FORCE&lt;/code&gt;, with no context, and the first two scenarios ran on an empty table until I noticed.&lt;/p&gt;

&lt;p&gt;The error is loud, it is correct, and it is annoying in exactly the place where people are in a hurry. The obvious repair is to relax the check:&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="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;tenant_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.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="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reads are still filtered, the seed goes through, the suite is green. It is the same shape as the &lt;code&gt;NULLIF&lt;/code&gt; trap from the previous article: a tempting fix that turns a loud failure into a silent one. And unlike &lt;code&gt;NULLIF&lt;/code&gt;, this one is not a degraded mode. It is a hole.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three policies, three assertions, four writes
&lt;/h2&gt;

&lt;p&gt;Same table, same two rows, same &lt;code&gt;FORCE&lt;/code&gt;, three shapes of policy. The three columns on the left are the assertions the thread built. &lt;strong&gt;Equality&lt;/strong&gt; is the double run: the owner and the serving role return the same set. &lt;strong&gt;Scoped&lt;/strong&gt; is the ordinary one: tenant 1 sees only its rows. &lt;strong&gt;Disjoint&lt;/strong&gt; is Marco's: tenant 2 sees something, and none of it is tenant 1's. Then tenant 1, under its own context, attempts four writes aimed at tenant 2.&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 | INSERT    INSERT..RETURNING  UPDATE..WHERE  UPDATE no WHERE
USING only                    GREEN    GREEN    GREEN  | refused       refused           refused        refused
USING + WITH CHECK (same)     GREEN    GREEN    GREEN  | refused       refused           refused        refused
USING + WITH CHECK (true)     GREEN    GREEN    GREEN  | ACCEPTED      refused           refused        ACCEPTED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first two rows are the sound ones. When &lt;code&gt;WITH CHECK&lt;/code&gt; is omitted, Postgres uses the &lt;code&gt;USING&lt;/code&gt; expression for it, so "USING only" is not a shortcut, it is the correct policy written short. Every cross-tenant write is refused on both.&lt;/p&gt;

&lt;p&gt;The bottom row is the one the seeding error pushes you towards. The same three assertions that caught every broken database in the previous article are green on it, and tenant 1 can file an invoice into tenant 2's books.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two refusals on that row are not what they look like
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;WITH CHECK (true)&lt;/code&gt; accepts everything, so what is refusing &lt;code&gt;UPDATE ... WHERE id = 1&lt;/code&gt; and &lt;code&gt;INSERT ... RETURNING id&lt;/code&gt; on that row?&lt;/p&gt;

&lt;p&gt;Not the write policy. It is the &lt;code&gt;USING&lt;/code&gt; expression of the read side, applied to the &lt;strong&gt;new&lt;/strong&gt; row, and it only applies because the statement reads a column. Footnote [a] of the "Policies Applied by Command Type" table in &lt;code&gt;CREATE POLICY&lt;/code&gt; says the &lt;code&gt;SELECT&lt;/code&gt; policy is checked against the new row when read access is required to it, "for example, a WHERE or RETURNING clause that refers to columns from the relation". The plain &lt;code&gt;INSERT&lt;/code&gt; does not read, so it passes. The same &lt;code&gt;INSERT&lt;/code&gt; with &lt;code&gt;RETURNING id&lt;/code&gt; reads, so it is refused. &lt;code&gt;UPDATE ... WHERE id = 1&lt;/code&gt; reads &lt;code&gt;id&lt;/code&gt;, refused. &lt;code&gt;UPDATE ... SET tenant_id = 2&lt;/code&gt; with no &lt;code&gt;WHERE&lt;/code&gt; reads nothing, accepted.&lt;/p&gt;

&lt;p&gt;So on that database the write side is guarded by the read policy, by accident, and only while the statement happens to read something. Take the read away and there is nothing left:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;before : tenant 1 = [1], tenant 2 = [2]
tenant 1 runs, under its own context: UPDATE invoice SET tenant_id = 2
after  : tenant 1 = [], tenant 2 = [1,2]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;USING&lt;/code&gt; still filters on the way in, so tenant 1 only touches its own rows. Then it hands all of them to tenant 2. The tenant that lost its data cannot see where it went, and the tenant that received it never asked for it. No error, no log line, and three green assertions.&lt;/p&gt;

&lt;p&gt;Two smaller results from the same bench, for completeness. &lt;code&gt;DELETE FROM invoice&lt;/code&gt; with no &lt;code&gt;WHERE&lt;/code&gt; under tenant 1 deletes tenant 1's rows only: &lt;code&gt;USING&lt;/code&gt; filters the existing rows, and a &lt;code&gt;DELETE&lt;/code&gt; has no new row to check. &lt;code&gt;UPDATE invoice SET amount = 0&lt;/code&gt; with no &lt;code&gt;WHERE&lt;/code&gt; is likewise confined to tenant 1. The hole is specifically a write that changes which tenant a row belongs to, or inserts a row under another tenant's label, on a policy whose &lt;code&gt;WITH CHECK&lt;/code&gt; no longer looks at &lt;code&gt;tenant_id&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fourth assertion is one line, and it belongs in the same test
&lt;/h2&gt;

&lt;p&gt;Equality and disjointness say the boundary separates what is already in the table. Neither says anything about what a tenant can put there. The missing assertion is: as tenant 1, attempt a write labelled tenant 2, and assert that it raises.&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;assertCrossTenantWriteRaises&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;$asTenant&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;$towardsTenant&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;$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="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;$asTenant&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;executeStatement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'INSERT INTO invoice (id, tenant_id, amount) VALUES (?, ?, ?)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;999&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$towardsTenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'a write labelled with another tenant went through'&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;DriverException&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;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;'42501'&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;getSQLState&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SQLSTATE matters. &lt;code&gt;42501&lt;/code&gt; is &lt;code&gt;insufficient_privilege&lt;/code&gt;, which is what a policy violation raises. Catching any exception would let a typo in the statement pass for a refusal.&lt;/p&gt;

&lt;p&gt;Run against the three policies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;USING only               GREEN (42501 raised)
USING + WITH CHECK       GREEN (42501 raised)
USING + WITH CHECK(true) RED   (insert accepted)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Red in exactly the row the other three sleep through. With the four assertions together, I no longer have a broken database that the suite stays green on. I had that sentence about three assertions a week ago, so take it for what it is: the next comment thread may well find the fifth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seed with the context, not around it
&lt;/h2&gt;

&lt;p&gt;The honest fix for the seeding error is not to weaken the policy, it is to seed the way the application writes: set the tenant context, insert that tenant's rows, set the next one. It is the same &lt;code&gt;set_config(?, ?, true)&lt;/code&gt; call the tests already use, inside the same transaction. A fixture that goes through the policy is also a fixture that proves the policy accepts legitimate writes, which is the positive half of the claim and worth having on its own.&lt;/p&gt;

&lt;p&gt;If you seed with a superuser or a &lt;code&gt;BYPASSRLS&lt;/code&gt; role instead, it works, and it puts the seed outside the boundary again. That is fine for a fixture as long as the assertions themselves stay inside it, which is what the previous article was about.&lt;/p&gt;

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

&lt;p&gt;Two bash scripts and three PHP files. They create a throwaway database and two roles, build the table under each policy, run the three read assertions and the four writes under the serving connection, show the damage from the unguarded &lt;code&gt;UPDATE&lt;/code&gt;, run the fourth assertion, 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. I wrote them alongside &lt;a href="https://shipanvil.com" rel="noopener noreferrer"&gt;ShipAnvil&lt;/a&gt;, a multi-tenant Symfony kit that scopes tenants with a Doctrine filter and ships no RLS of its own. There is no product in them either: a table called &lt;code&gt;invoice&lt;/code&gt;, two rows, and a policy with a hole in it.&lt;/p&gt;

&lt;p&gt;Thanks to Marco for the sentence that started this: the pair stays inside the boundary. It does. It also only reads. If you are running RLS in a Symfony app, the cheapest thing you can do this week is open your policies and look at what &lt;code&gt;WITH CHECK&lt;/code&gt; says. If one of them says &lt;code&gt;true&lt;/code&gt;, you already know what your isolation suite cannot see.&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>php</category>
      <category>postgres</category>
      <category>testing</category>
    </item>
    <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;




&lt;p&gt;&lt;em&gt;Written by Eric Mollenthiel, freelance Symfony developer in Lyon, France.&lt;br&gt;
More at &lt;a href="https://mollenthiel.fr" rel="noopener noreferrer"&gt;mollenthiel.fr&lt;/a&gt;.&lt;/em&gt;&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;




&lt;p&gt;&lt;em&gt;Written by Eric Mollenthiel, freelance Symfony developer in Lyon, France.&lt;br&gt;
More at &lt;a href="https://mollenthiel.fr" rel="noopener noreferrer"&gt;mollenthiel.fr&lt;/a&gt;.&lt;/em&gt;&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;




&lt;p&gt;&lt;em&gt;Written by Eric Mollenthiel, freelance Symfony developer in Lyon, France.&lt;br&gt;
More at &lt;a href="https://mollenthiel.fr" rel="noopener noreferrer"&gt;mollenthiel.fr&lt;/a&gt;.&lt;/em&gt;&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, 11&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, 11 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;




&lt;p&gt;&lt;em&gt;Written by Eric Mollenthiel, freelance Symfony developer in Lyon, France.&lt;br&gt;
More at &lt;a href="https://mollenthiel.fr" rel="noopener noreferrer"&gt;mollenthiel.fr&lt;/a&gt;.&lt;/em&gt;&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;




&lt;p&gt;&lt;em&gt;Written by Eric Mollenthiel, freelance Symfony developer in Lyon, France.&lt;br&gt;
More at &lt;a href="https://mollenthiel.fr" rel="noopener noreferrer"&gt;mollenthiel.fr&lt;/a&gt;.&lt;/em&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;




&lt;p&gt;&lt;em&gt;Written by Eric Mollenthiel, freelance Symfony developer in Lyon, France.&lt;br&gt;
More at &lt;a href="https://mollenthiel.fr" rel="noopener noreferrer"&gt;mollenthiel.fr&lt;/a&gt;.&lt;/em&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;




&lt;p&gt;&lt;em&gt;Written by Eric Mollenthiel, freelance Symfony developer in Lyon, France.&lt;br&gt;
More at &lt;a href="https://mollenthiel.fr" rel="noopener noreferrer"&gt;mollenthiel.fr&lt;/a&gt;.&lt;/em&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;




&lt;p&gt;&lt;em&gt;Written by Eric Mollenthiel, freelance Symfony developer in Lyon, France.&lt;br&gt;
More at &lt;a href="https://mollenthiel.fr" rel="noopener noreferrer"&gt;mollenthiel.fr&lt;/a&gt;.&lt;/em&gt;&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;




&lt;p&gt;&lt;em&gt;Written by Eric Mollenthiel, freelance Symfony developer in Lyon, France.&lt;br&gt;
More at &lt;a href="https://mollenthiel.fr" rel="noopener noreferrer"&gt;mollenthiel.fr&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>programming</category>
      <category>software</category>
      <category>softwareengineering</category>
    </item>
  </channel>
</rss>
