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

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

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

&lt;p&gt;Nothing in my usual instruments could see the problem, so I stopped looking at&lt;br&gt;
them and asked the site directly, once per user agent, at its real public&lt;br&gt;
address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;ua &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"Mozilla/5.0 (compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot)"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"Mozilla/5.0 (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"Mozilla/5.0 (compatible; Claude-SearchBot/1.0; +claudebot@anthropic.com)"&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}'&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ua&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://www.loviam.com/&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;  &lt;/span&gt;&lt;span class="nv"&gt;$ua&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;Here is the core of it, in PHP, working entirely in integer cents:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cd"&gt;/**
 * @param array&amp;lt;int, float&amp;gt; $weights  participant id =&amp;gt; weight
 * @return array&amp;lt;int, int&amp;gt;            participant id =&amp;gt; cents
 */&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;prorate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$totalCents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$weights&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$weights&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sum&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\InvalidArgumentException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'No shares to split.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$amounts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="nv"&gt;$remainders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="nv"&gt;$allocated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$weights&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$weight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$exact&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$totalCents&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$weight&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nv"&gt;$sum&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$floor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$exact&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

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

    &lt;span class="nv"&gt;$left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$totalCents&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$allocated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$left&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$remainders&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nb"&gt;usort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$remainders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$weights&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$cmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$remainders&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$remainders&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cmp&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$cmp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="nv"&gt;$cmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$weights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$weights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$cmp&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$cmp&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$a&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;array_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$left&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="nv"&gt;$amounts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$amounts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$left&lt;/code&gt; is bounded by the number of participants minus one, so this is never more than a handful of increments.&lt;/p&gt;

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

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

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

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

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

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

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

&lt;/div&gt;



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

&lt;p&gt;That is not a nice-to-have, it is a test oracle. It turns "did I get the money maths right" into a single assertion you can run after every operation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;testBalancesAlwaysSumToZero&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$balances&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;calculator&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;forGroup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$group&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertSame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;array_sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Balance&lt;/span&gt; &lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;cents&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="nv"&gt;$balances&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any bug that loses or invents a cent anywhere trips this: a bad split, a mishandled refund, a currency conversion, a deleted participant. It is the cheapest high-value test in the codebase, and it only exists because the splitter is exact.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;PHP has had libsodium in core since 7.2. A secretbox is authenticated encryption (XSalsa20 for confidentiality, Poly1305 for integrity), it takes a 32-byte key and a nonce, and there is no way to hold it wrong except reusing a nonce, which &lt;code&gt;random_bytes()&lt;/code&gt; makes a non-issue.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TokenCipher&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$base64Key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;base64_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$base64Key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="no"&gt;\SODIUM_CRYPTO_SECRETBOX_KEYBYTES&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nf"&gt;\strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\InvalidArgumentException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s1"&gt;'The encryption key must be 32 random bytes, base64-encoded.'&lt;/span&gt;
            &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$plaintext&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;random_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;\SODIUM_CRYPTO_SECRETBOX_NONCEBYTES&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;base64_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$nonce&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;sodium_crypto_secretbox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$plaintext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$nonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$encoded&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;base64_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$encoded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$raw&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;\strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="no"&gt;\SODIUM_CRYPTO_SECRETBOX_NONCEBYTES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Invalid ciphertext.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;\SODIUM_CRYPTO_SECRETBOX_NONCEBYTES&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$plaintext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sodium_crypto_secretbox_open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;\SODIUM_CRYPTO_SECRETBOX_NONCEBYTES&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nv"&gt;$nonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$plaintext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Decryption failed (wrong key or corrupted data).'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$plaintext&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth stating out loud, because they are what people get wrong when they roll this by hand:&lt;/p&gt;

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

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

&lt;p&gt;I generate the key with one line and put it in the environment, next to the database URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="n"&gt;php&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="s2"&gt;"echo base64_encode(random_bytes(32)), PHP_EOL;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EncryptedJsonType&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Type&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;string&lt;/span&gt; &lt;span class="no"&gt;NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'encrypted_json'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;?TokenCipher&lt;/span&gt; &lt;span class="nv"&gt;$cipher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="cd"&gt;/** Test seam: lets a test drive the type without touching the environment. */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;setCipher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;?TokenCipher&lt;/span&gt; &lt;span class="nv"&gt;$cipher&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nv"&gt;$cipher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$cipher&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getName&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;NAME&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getSQLDeclaration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$column&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;AbstractPlatform&lt;/span&gt; &lt;span class="nv"&gt;$platform&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$platform&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getClobTypeDeclarationSQL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$column&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;convertToDatabaseValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;AbstractPlatform&lt;/span&gt; &lt;span class="nv"&gt;$platform&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;\JSON_THROW_ON_ERROR&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;convertToPHPValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;AbstractPlatform&lt;/span&gt; &lt;span class="nv"&gt;$platform&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;\is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// Rows written before the switch are still plain JSON.&lt;/span&gt;
        &lt;span class="nv"&gt;$json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'{'&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'['&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;
            &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;json_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;\JSON_THROW_ON_ERROR&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;\is_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$decoded&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'An encrypted JSON column must hold an object.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

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

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;TokenCipher&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Doctrine builds its types statically, outside the container: read the&lt;/span&gt;
        &lt;span class="c1"&gt;// key the way Dotenv exposes it to every process (bin/console, FPM,&lt;/span&gt;
        &lt;span class="c1"&gt;// the messenger worker and migrations alike).&lt;/span&gt;
        &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$_SERVER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ENCRYPTION_KEY'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nv"&gt;$_ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ENCRYPTION_KEY'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nv"&gt;$cipher&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TokenCipher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;\is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Registered once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;doctrine&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;dbal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;encrypted_json&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;App\Doctrine\EncryptedJsonType&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And used like any other type, which is the whole payoff. Everything above the entity, the repositories, the controllers, the PDF renderer, the tests, keeps handling a plain PHP array:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cd"&gt;/** @var array&amp;lt;string, mixed&amp;gt; */&lt;/span&gt;
&lt;span class="na"&gt;#[ORM\Column(type: EncryptedJsonType::NAME)]&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$buyer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;p&gt;&lt;strong&gt;The static setter is a test seam, and it earns its keep.&lt;/strong&gt; A unit test sets a fixed key, exercises the round trip, and asserts on the stored value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;testTheStoredValueLeaksNothing&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$stored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;convertToDatabaseValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;BUYER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertStringNotContainsString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Claire'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$stored&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertStringNotContainsString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'example.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$stored&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;up&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Schema&lt;/span&gt; &lt;span class="nv"&gt;$schema&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addSql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ALTER TABLE invoice ALTER buyer TYPE TEXT'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;postUp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Schema&lt;/span&gt; &lt;span class="nv"&gt;$schema&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;getType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EncryptedJsonType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;NAME&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$platform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getDatabasePlatform&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;iterateAssociative&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SELECT id, buyer FROM invoice'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$buyer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'buyer'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;\is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$buyer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$buyer&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'{'&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$buyer&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="s1"&gt;'['&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$buyer&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// already encrypted&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'invoice'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'buyer'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$type&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;convertToDatabaseValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;json_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$buyer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$platform&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;iterateAssociative()&lt;/code&gt; rather than &lt;code&gt;fetchAllAssociative()&lt;/code&gt;: the migration streams, so the memory it uses does not depend on how many rows you have. And the same loop lives in a console command (&lt;code&gt;app:encrypt-buyers&lt;/code&gt;), because the migration is not the only way a plaintext row can appear. A rollback to a previous release, or a dump restored from before the switch, both produce rows the migration will never see again. Having a command means the answer to "prove the database holds no readable personal data" is something you run, not something you believe.&lt;/p&gt;

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

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

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

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

&lt;p&gt;In my case exactly one query touched it, and it came from an obligation I could not drop: the GDPR data-request webhook, which must answer "what do you hold about this customer?" given an email address. It used to be a JSON predicate in SQL. It became a stream in PHP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cd"&gt;/**
 * The buyer block is encrypted at rest, so no SQL predicate can match on
 * it: the rows are streamed and compared in PHP (case-insensitively, as
 * an email is). This runs on the GDPR customer webhooks only, never on a
 * merchant-facing page.
 *
 * @return list&amp;lt;string&amp;gt;
 */&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;findNumbersByShopAndBuyerEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Shop&lt;/span&gt; &lt;span class="nv"&gt;$shop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$connection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getEntityManager&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getConnection&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;getType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EncryptedJsonType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;NAME&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$platform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$connection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getDatabasePlatform&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$needle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;mb_strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="nv"&gt;$numbers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="nv"&gt;$rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$connection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;iterateAssociative&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'SELECT number, buyer FROM invoice WHERE shop_id = :shop ORDER BY number'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'shop'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$shop&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getId&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$rows&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$buyer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$type&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;convertToPHPValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'buyer'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$platform&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$buyerEmail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;\is_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$buyer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$buyer&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'number'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;\is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$buyerEmail&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;\is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;mb_strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$buyerEmail&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$needle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$numbers&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$numbers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yes, that is a full scan per tenant, decrypting as it goes. I am fine with it, and the docblock says why: it runs on a webhook that fires a handful of times a year, never on a page a merchant waits for. The scope is narrowed by &lt;code&gt;shop_id&lt;/code&gt;, which is indexed, so the scan is over one tenant's rows, not the table.&lt;/p&gt;

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

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

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

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

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

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

&lt;p&gt;So the audit trail names the actor and the object, never the data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cd"&gt;/**
 * The trail names the shop, the document and the channel, never the
 * personal data itself: a log file must not become a second,
 * unencrypted copy of what the database encrypts.
 */&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;documentRead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Invoice&lt;/span&gt; &lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$format&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Document read.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'shop'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getShop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getShopDomain&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="s1"&gt;'document'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getNumber&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="s1"&gt;'channel'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 'admin' (the merchant) or 'buyer-link'&lt;/span&gt;
        &lt;span class="s1"&gt;'format'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$format&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While you are there, check the three other places that leak the same way: exception context (an entity dumped into a stack trace), your error tracker's breadcrumbs, and the Symfony profiler in any environment where it is enabled against real data.&lt;/p&gt;

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;p&gt;The failure is not the encryption, it is that &lt;code&gt;convertToPHPValue()&lt;/code&gt; was asking the payload what it was. A payload is not a trustworthy narrator. The format and the key version belong next to the data, as data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;string&lt;/span&gt; &lt;span class="no"&gt;PREFIX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'enc:v1:'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;convertToDatabaseValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;AbstractPlatform&lt;/span&gt; &lt;span class="nv"&gt;$platform&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$keyring&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;keyring&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;PREFIX&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$keyring&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;activeId&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;':'&lt;/span&gt;
        &lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$keyring&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;active&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;\JSON_THROW_ON_ERROR&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;convertToPHPValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;AbstractPlatform&lt;/span&gt; &lt;span class="nv"&gt;$platform&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;\is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;str_starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;PREFIX&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Fail closed: a readable row is a bug or an intrusion, never a fallback.&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'This value is not in the "'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;PREFIX&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'" envelope.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$keyId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$ciphertext&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;keyring&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$keyId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ciphertext&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things change, and only the first is obvious.&lt;/p&gt;

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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




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

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

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

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

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

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

&lt;p&gt;That split is worth stating out loud because it inverts the usual&lt;br&gt;
Symfony instinct (secure the controller, render the data server-side).&lt;br&gt;
Here, rendering data into the shell would mean authenticating a page&lt;br&gt;
load that has no credentials to authenticate with. The payoff is that&lt;br&gt;
the shell is trivially cacheable and the security surface is one&lt;br&gt;
firewall over one path prefix.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/packages/security.yaml&lt;/span&gt;
&lt;span class="na"&gt;firewalls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Webhooks authenticate via HMAC signature in the controller, not a firewall.&lt;/span&gt;
    &lt;span class="na"&gt;webhooks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;^/webhooks&lt;/span&gt;
        &lt;span class="na"&gt;security&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="c1"&gt;# Embedded admin API: App Bridge session token (JWT) on every request.&lt;/span&gt;
    &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;^/api&lt;/span&gt;
        &lt;span class="na"&gt;stateless&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;users_in_memory&lt;/span&gt;
        &lt;span class="na"&gt;custom_authenticators&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;App\Security\SessionTokenAuthenticator&lt;/span&gt;
    &lt;span class="na"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;lazy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;users_in_memory&lt;/span&gt;

&lt;span class="na"&gt;access_control&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;^/api&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;roles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;ROLE_SHOP&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The session token is a JWT signed HS256 with your app's client secret.&lt;br&gt;
Verifying the signature is the part everyone does. The four claim checks&lt;br&gt;
after it are the part that gets skipped, and each one closes a real hole:&lt;br&gt;
without &lt;code&gt;aud&lt;/code&gt; you accept tokens minted for a &lt;em&gt;different app&lt;/em&gt; that happens&lt;br&gt;
to share nothing but the algorithm; without the &lt;code&gt;iss&lt;/code&gt;/&lt;code&gt;dest&lt;/code&gt; match you&lt;br&gt;
accept a token that claims one shop in one place and another shop&lt;br&gt;
elsewhere.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/Shopify/SessionTokenVerifier.php&lt;/span&gt;
&lt;span class="nv"&gt;$expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$encodedHeader&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'.'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$encodedPayload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;apiSecret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$signature&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;hash_equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$signature&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InvalidSessionTokenException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Invalid JWT signature.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;clock&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getTimestamp&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// exp / nbf, with a small leeway for clock skew&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;\is_int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$exp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;LEEWAY_SECONDS&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nv"&gt;$exp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InvalidSessionTokenException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Session token has expired.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// aud must be *our* client id&lt;/span&gt;
&lt;span class="nv"&gt;$audiences&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;\is_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$aud&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$aud&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$aud&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;\in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$audiences&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InvalidSessionTokenException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Session token audience mismatch.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// dest is the shop; iss must live on the same host&lt;/span&gt;
&lt;span class="nv"&gt;$shopDomain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;parse_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$dest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;\PHP_URL_HOST&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;\is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$shopDomain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nc"&gt;ShopDomain&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;isValid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$shopDomain&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InvalidSessionTokenException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'dest claim is not a valid shop domain.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;parse_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$iss&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;\PHP_URL_HOST&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$shopDomain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InvalidSessionTokenException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'iss and dest claims do not match.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that cost time if you get them wrong. Use &lt;code&gt;hash_equals&lt;/code&gt;, not&lt;br&gt;
&lt;code&gt;===&lt;/code&gt;, on the signature: this is the textbook timing-attack surface and&lt;br&gt;
it costs one function name. And take the clock from&lt;br&gt;
&lt;code&gt;Symfony\Component\Clock\ClockInterface&lt;/code&gt; rather than calling &lt;code&gt;time()&lt;/code&gt;,&lt;br&gt;
so your test suite can produce an expired token without sleeping.&lt;/p&gt;

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

&lt;p&gt;A session token proves &lt;em&gt;who is asking&lt;/em&gt;. It does not let you call the&lt;br&gt;
Admin API. For that you trade it for an access token, using OAuth 2.0&lt;br&gt;
token exchange (RFC 8693) rather than the old authorization-code dance&lt;br&gt;
with redirects. With managed installation, this is the entire install&lt;br&gt;
flow: no &lt;code&gt;/auth&lt;/code&gt; route, no redirect, no callback. The first authenticated&lt;br&gt;
request from a new shop simply performs the exchange.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/Shopify/TokenExchanger.php&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;GRANT_TYPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'urn:ietf:params:oauth:grant-type:token-exchange'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;SUBJECT_TOKEN_TYPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'urn:ietf:params:oauth:token-type:id_token'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;OFFLINE_TOKEN_TYPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'urn:shopify:params:oauth:token-type:offline-access-token'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;exchangeForOfflineToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$shopDomain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$sessionToken&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;AccessToken&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;requestToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$shopDomain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'grant_type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;GRANT_TYPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'subject_token'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$sessionToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'subject_token_type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SUBJECT_TOKEN_TYPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'requested_token_type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;OFFLINE_TOKEN_TYPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'expiring'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;p&gt;Symfony's custom authenticator maps onto this cleanly. The passport is&lt;br&gt;
self-validating because the JWT signature &lt;em&gt;is&lt;/em&gt; the credential check.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/Security/SessionTokenAuthenticator.php&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;supports&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;str_starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Authorization'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'Bearer '&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;authenticate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Passport&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$jwt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Authorization'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;\strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Bearer '&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$sessionToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;sessionTokenVerifier&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$jwt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$shop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;shopInstaller&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ensureInstalled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sessionToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;InvalidSessionTokenException&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nc"&gt;TokenExchangeFailedException&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CustomUserMessageAuthenticationException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMessage&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SelfValidatingPassport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UserBadge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;$sessionToken&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;shopDomain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;ShopUser&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ShopUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$shop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$sessionToken&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;unauthorized&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;JsonResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'error'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Unauthorized.'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;HTTP_UNAUTHORIZED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'X-Shopify-Retry-Invalid-Session-Request'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'1'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;X-Shopify-Retry-Invalid-Session-Request&lt;/code&gt; is worth a paragraph of its&lt;br&gt;
own. Tokens last sixty seconds, so a page left open on a merchant's&lt;br&gt;
second monitor will eventually fire a request with a token that expired&lt;br&gt;
while they were in another tab. Return a bare 401 and the merchant sees&lt;br&gt;
an error. Return 401 &lt;em&gt;with that header&lt;/em&gt; and App Bridge silently fetches&lt;br&gt;
a fresh token and retries once. One header turns a class of user-visible&lt;br&gt;
failures into nothing at all.&lt;/p&gt;

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

&lt;p&gt;Webhooks carry no session token. They are signed: HMAC-SHA256 over the&lt;br&gt;
&lt;strong&gt;raw request body&lt;/strong&gt;, base64-encoded in &lt;code&gt;X-Shopify-Hmac-Sha256&lt;/code&gt;. Raw&lt;br&gt;
means raw, before any JSON decoding, before any middleware touches it.&lt;br&gt;
Shopify's automated App Store review sends a deliberately mis-signed&lt;br&gt;
webhook and requires a 401.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;verifyWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$hmacHeader&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$hmacHeader&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;base64_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;hash_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;apiSecret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;hash_equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$hmacHeader&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second signature type is the sharp one. Links from the Shopify admin&lt;br&gt;
carry an &lt;code&gt;hmac&lt;/code&gt; query parameter computed over the sorted query string,&lt;br&gt;
and the message Shopify signs is &lt;strong&gt;not&lt;/strong&gt; a URL-encoded query string. Only&lt;br&gt;
&lt;code&gt;&amp;amp;&lt;/code&gt;, &lt;code&gt;%&lt;/code&gt; and &lt;code&gt;=&lt;/code&gt; are escaped, and only in the places shown below.&lt;br&gt;
Reaching for &lt;code&gt;http_build_query()&lt;/code&gt; here produces a signature that is&lt;br&gt;
wrong for any value containing a space or a slash, which is exactly the&lt;br&gt;
kind of bug that passes every test you thought to write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$pairs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$query&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$pairs&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;strtr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&amp;amp;'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'%26'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'%25'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'='&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'%3D'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'='&lt;/span&gt;
        &lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;strtr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&amp;amp;'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'%26'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'%25'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;implode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'&amp;amp;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$pairs&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;apiSecret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;&lt;strong&gt;Access tokens encrypted at rest.&lt;/strong&gt; A shop's access token is a&lt;br&gt;
credential for someone else's business. Storing it in plaintext means a&lt;br&gt;
read-only SQL injection anywhere in your app hands over every merchant's&lt;br&gt;
store. Libsodium makes this eight lines, and PHP ships it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/Shopify/TokenCipher.php — XSalsa20-Poly1305 secretbox&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$plaintext&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;random_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;\SODIUM_CRYPTO_SECRETBOX_NONCEBYTES&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;base64_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$nonce&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;sodium_crypto_secretbox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$plaintext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$nonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key is 32 random bytes, base64-encoded, in an environment variable,&lt;br&gt;
and the constructor rejects anything else at boot rather than at the&lt;br&gt;
first decrypt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A frame-ancestors CSP scoped to the current shop.&lt;/strong&gt; Your app must be&lt;br&gt;
frameable by the merchant's admin and by nobody else, which means the&lt;br&gt;
header is computed per request from the &lt;code&gt;shop&lt;/code&gt; parameter, not set once&lt;br&gt;
in the vhost:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/EventListener/EmbeddedAppHeadersListener.php&lt;/span&gt;
&lt;span class="nv"&gt;$frameAncestors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ShopDomain&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;isValid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$shop&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;\sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'frame-ancestors https://%s https://admin.shopify.com;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$shop&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"frame-ancestors 'none';"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getResponse&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Content-Security-Policy'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$frameAncestors&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;p&gt;Worth knowing whoever you build for. This email subject line lost its&lt;br&gt;
first two words in production, silently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;digest.attachment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Attached:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;your&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;restock&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;list&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(%count%&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;items).'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

</description>
      <category>product</category>
      <category>softwareengineering</category>
      <category>testing</category>
    </item>
    <item>
      <title>Building a production-grade SaaS foundation in Symfony in 2026 — lessons from building ShipAnvil</title>
      <dc:creator>Eric Mollenthiel</dc:creator>
      <pubDate>Mon, 20 Jul 2026 12:51:17 +0000</pubDate>
      <link>https://dev.to/mollenthiel/building-a-production-grade-saas-foundation-in-symfony-in-2026-lessons-from-building-shipanvil-b8a</link>
      <guid>https://dev.to/mollenthiel/building-a-production-grade-saas-foundation-in-symfony-in-2026-lessons-from-building-shipanvil-b8a</guid>
      <description>&lt;p&gt;I've been writing Symfony professionally for 15 years. Over those years I started enough SaaS projects to notice an embarrassing pattern: the first two or three weeks were never about the product. They were email verification, password reset, 2FA, billing webhooks, tenant scoping, transactional emails, and a deploy checklist I kept half-remembering.&lt;/p&gt;

&lt;p&gt;This year I built that foundation one final time — properly, with tests — and turned it into a product (&lt;a href="https://shipanvil.com" rel="noopener noreferrer"&gt;ShipAnvil&lt;/a&gt;). This article is the technical write-up: the architecture decisions I'd defend in a code review, the ones that surprised me, and the parts that are harder than they look. Everything here stands on its own — you can take these patterns and build your own foundation with them.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The stack: boring on purpose, and zero Node.js
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Symfony 7.4 LTS&lt;/strong&gt; + PHP 8.4/8.5, PostgreSQL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The LAST stack&lt;/strong&gt;: AssetMapper, Stimulus, Turbo, Live Components.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailwind CSS v4&lt;/strong&gt; via &lt;code&gt;symfonycasts/tailwind-bundle&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The controversial choice is the absence of Node.js. AssetMapper serves ES modules with an importmap; the Tailwind bundle wraps a standalone binary. The practical consequence is bigger than it sounds: a new machine goes from &lt;code&gt;git clone&lt;/code&gt; to a running app with PHP and PostgreSQL alone. No lockfile drift, no build pipeline to babysit in CI, one less runtime in production.&lt;/p&gt;

&lt;p&gt;In 2024 I would have hedged on this. In 2026, after shipping real features with Live Components and Turbo, I wouldn't go back for a standard SaaS UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Billing: one interface, two providers, and a fake one
&lt;/h2&gt;

&lt;p&gt;The single highest-leverage decision in the codebase. Stripe is the default answer, but Merchant-of-Record providers (Lemon Squeezy, Paddle) solve VAT for solo developers — a very real pain if you sell from the EU. I wanted both, swappable by env var:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[AutoconfigureTag('billing.payment_provider')]&lt;/span&gt;
&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;PaymentProviderInterface&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;name&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;BillingProvider&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="cd"&gt;/** Creates a hosted checkout session for the plan and returns its URL. */&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;createCheckoutUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;Organization&lt;/span&gt; &lt;span class="nv"&gt;$organization&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;Plan&lt;/span&gt; &lt;span class="nv"&gt;$plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$successUrl&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;$cancelUrl&lt;/span&gt;&lt;span class="p"&gt;,&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="cd"&gt;/** Provider-hosted customer portal (payment methods, invoices, cancellation…). */&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;createPortalUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Subscription&lt;/span&gt; &lt;span class="nv"&gt;$subscription&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;$returnUrl&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;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;cancelAtPeriodEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Subscription&lt;/span&gt; &lt;span class="nv"&gt;$subscription&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;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Subscription&lt;/span&gt; &lt;span class="nv"&gt;$subscription&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="c1"&gt;// … webhook verification, see below&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three implementations: &lt;code&gt;StripeProvider&lt;/code&gt;, &lt;code&gt;LemonSqueezyProvider&lt;/code&gt;, and — the one I underestimated — &lt;code&gt;FakeProvider&lt;/code&gt;. The fake one runs the entire subscription lifecycle (checkout, upgrade, dunning, cancellation) locally with no account, no API key, no network. It exists for tests, but it turned out to be the best onboarding feature: you can develop your whole billing UX before deciding which provider you'll even use.&lt;/p&gt;

&lt;p&gt;Two non-obvious rules this interface encodes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Hosted checkout only.&lt;/strong&gt; Embedding payment forms means PCI scope and provider-specific JS. Redirecting to Stripe Checkout / LS hosted checkout keeps the integration surface to "create a URL, handle a webhook".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The organization id must travel with the checkout&lt;/strong&gt; (metadata/custom data), because the webhook is the source of truth that attaches the subscription — the redirect back to your app is not guaranteed to happen.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  3. Webhooks: the part that will page you at 3am
&lt;/h2&gt;

&lt;p&gt;Every billing tutorial shows the happy path: event arrives, update the database. Production has other plans — duplicate deliveries, out-of-order events, retries during your deploy window. The pattern that survives all of that is &lt;strong&gt;verify → store → ACK → process async&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="na"&gt;#[Route('/webhooks/{providerSlug}', name: 'billing_webhook', methods: ['POST'])]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__invoke&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;$providerSlug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;JsonResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$provider&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;providers&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fromSlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$providerSlug&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&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;$identity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$provider&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;verifyWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// signature check on the RAW body&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;WebhookVerificationFailed&lt;/span&gt; &lt;span class="nv"&gt;$exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'error'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Invalid webhook signature or payload.'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$existing&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;webhookEvents&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;findOneByProviderEventId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$provider&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;$identity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;eventId&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;$existing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$existing&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isProcessed&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'already processed'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="c1"&gt;// Stored but not (successfully) processed yet: requeue it.&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;bus&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;dispatch&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;ProcessWebhookEvent&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;$existing&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'requeued'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;202&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebhookEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$provider&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;$identity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$identity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$identity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// persist + dispatch…&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The details that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Verify the signature on the raw request body.&lt;/strong&gt; Any framework normalization (decoding, re-encoding) breaks HMAC comparison. This is the most common webhook bug I've seen in the wild.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency lives in the database&lt;/strong&gt;, as a unique constraint on &lt;code&gt;(provider, event_id)&lt;/code&gt; — not in application logic. Two PHP-FPM workers receiving the same delivery concurrently will both pass an &lt;code&gt;if&lt;/code&gt; check; only one will survive the constraint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ACK before processing.&lt;/strong&gt; The webhook HTTP response should take milliseconds. The actual state change happens in a Messenger handler with retries. Symfony's Doctrine transport means this needs no Redis — a database table is a perfectly good queue at this scale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test with replayed fixtures.&lt;/strong&gt; The suite replays full billing lifecycles from &lt;em&gt;signed&lt;/em&gt; webhook fixtures — including duplicates and out-of-order deliveries. Those are test cases, not incidents.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. Multi-tenancy: a Doctrine filter, and the tests that earn the trust
&lt;/h2&gt;

&lt;p&gt;For a team-based SaaS, the pragmatic model is single-database with an &lt;code&gt;organization_id&lt;/code&gt; column. The risk is the day someone writes a query and forgets the &lt;code&gt;WHERE&lt;/code&gt;. Doctrine's SQLFilter closes that hole globally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;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;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="s1"&gt;'organization_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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A request subscriber enables the filter as soon as an authenticated user with a current organization is detected. Every query against an entity implementing &lt;code&gt;OrganizationOwnedInterface&lt;/code&gt; gets scoped automatically — ORM queries, lazy-loads, joins.&lt;/p&gt;

&lt;p&gt;Two honest caveats, because this pattern is often oversold:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Console commands and Messenger workers run unfiltered.&lt;/strong&gt; There is no "current request" there. You must scope explicitly in async code — and document that loudly, which is exactly the kind of footgun that belongs in a comment on the filter class itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A filter you haven't tested is a hope, not a guarantee.&lt;/strong&gt; The test suite creates two organizations and asserts that org A's queries can never see org B's rows — including through relations. If you take one thing from this article: write the isolation tests &lt;em&gt;before&lt;/em&gt; you trust the filter.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Plan gating then becomes declarative — an attribute on the controller, and a listener that redirects lower plans to the billing page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[RequiresPlan('pro')]&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;advancedReports&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. The AI module: HttpClient, not an SDK
&lt;/h2&gt;

&lt;p&gt;Every SaaS I talk to is adding an AI feature, so the foundation ships a Claude integration. Deliberate choice: &lt;strong&gt;no vendor SDK&lt;/strong&gt; — a thin client on Symfony's HttpClient with retries, timeouts and SSE streaming. An LLM API is two endpoints; an SDK is a dependency tree you don't control.&lt;/p&gt;

&lt;p&gt;The pattern I use most is structured extraction via &lt;strong&gt;forced tool use&lt;/strong&gt;. You give the model exactly one tool whose input schema is your target structure, and you force it to call that tool. The answer &lt;em&gt;is&lt;/em&gt; the structure — never prose around it, never "Here's your JSON:":&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;$result&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;aiClient&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// tool_choice forces the tool&lt;/span&gt;

&lt;span class="nv"&gt;$input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;toolInput&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;AiRequestFailed&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;'The model did not call the "%s" tool.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$tool&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tool&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;requiredProperties&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;$property&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;\array_key_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$property&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$input&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;AiRequestFailed&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;'The extraction is missing the required "%s" property.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$property&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ExtractionResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the validation after the call: a model can return a tool call that omits required properties. Trust, but verify — then your callers get a typed array, every time.&lt;/p&gt;

&lt;p&gt;Token usage is metered per organization in a plain table. That's the seed of usage-based billing later, and it answers "which customer is costing me money" on day one. The whole module hides behind an interface with a fake implementation, so dev and CI run without an API key — same philosophy as billing.&lt;/p&gt;

&lt;p&gt;One more 2026 reality: developers buy code they'll modify &lt;em&gt;with an agent next to them&lt;/em&gt;. The repo ships a &lt;code&gt;CLAUDE.md&lt;/code&gt; describing the architecture, conventions and test commands. It costs an afternoon and it changes what Claude Code or Cursor can do with the codebase on day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Quality as a feature, not a slogan
&lt;/h2&gt;

&lt;p&gt;Numbers from the codebase as of this week: &lt;strong&gt;379 tests, 1,667 assertions, PHPStan at level max with zero errors&lt;/strong&gt;, CS-Fixer, and a CI pipeline (lint + static analysis + tests against a real PostgreSQL service).&lt;/p&gt;

&lt;p&gt;The non-obvious lesson: the test suite changed &lt;em&gt;what I dared to build&lt;/em&gt;. Replayed webhook lifecycles made the double-provider billing tractable. Isolation tests made the Doctrine filter trustworthy. On a foundation meant to outlive its author's attention, tests aren't insurance — they're the enabling technology.&lt;/p&gt;

&lt;p&gt;The other quality feature is rehearsed installation. &lt;code&gt;make init&lt;/code&gt; checks your PHP version, installs dependencies, creates the database, builds the CSS and runs the full test suite. The deploy documentation is a step-by-step VPS recipe (PHP-FPM pool, Apache vhost, certbot, systemd worker for Messenger, OPcache with &lt;code&gt;validate_timestamps=0&lt;/code&gt; and what that implies for deploys) — because "works on my machine" is where most boilerplates quietly end.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell you to steal
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;An &lt;strong&gt;interface in front of your payment provider&lt;/strong&gt;, with a fake implementation — even if you only ever use Stripe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify → store idempotently → ACK → process async&lt;/strong&gt; for every webhook, with the unique constraint in the database.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;Doctrine SQLFilter for tenancy&lt;/strong&gt;, plus the isolation tests that make it trustworthy — and explicit scoping in CLI/workers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forced tool use&lt;/strong&gt; for any LLM feature that feeds your domain logic.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;CLAUDE.md&lt;/code&gt; in any codebase another human (or agent) will inherit.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Full disclosure: this article comes out of building &lt;a href="https://shipanvil.com" rel="noopener noreferrer"&gt;ShipAnvil&lt;/a&gt;, a commercial Symfony SaaS boilerplate I just launched (the site itself runs on it, and there's a public demo account on the landing page). The patterns above are reproducible from this write-up alone — but if you'd rather start from the tested implementation, that's what it's for. Criticism and questions welcome in the comments; I answer everything.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>symfony</category>
      <category>saas</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
