<?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>Forem: Sharjeel Zubair</title>
    <description>The latest articles on Forem by Sharjeel Zubair (@sharjeelz).</description>
    <link>https://forem.com/sharjeelz</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F572792%2F423d91ce-b404-4ecc-9236-9705d7f6a4c8.png</url>
      <title>Forem: Sharjeel Zubair</title>
      <link>https://forem.com/sharjeelz</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/sharjeelz"/>
    <language>en</language>
    <item>
      <title>I Pushed Our Backend Repo to a Public GitHub by Accident. Here's What Happened in 47 Minutes</title>
      <dc:creator>Sharjeel Zubair</dc:creator>
      <pubDate>Sun, 26 Apr 2026 06:01:36 +0000</pubDate>
      <link>https://forem.com/sharjeelz/i-pushed-our-backend-repo-to-a-public-github-by-accident-heres-what-happened-in-47-minutes-4df6</link>
      <guid>https://forem.com/sharjeelz/i-pushed-our-backend-repo-to-a-public-github-by-accident-heres-what-happened-in-47-minutes-4df6</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Leaking real source code (not just &lt;code&gt;.env&lt;/code&gt; files) exposes your business logic, internal APIs, hardcoded secrets buried deep in commits, and your auth flow. Bots will find it within minutes. Here's a timeline of what actually happens and how to respond.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Mistake
&lt;/h2&gt;

&lt;p&gt;I was helping a friend split their monolith into two repos. One was meant to be open-sourced (a CLI tool), the other was their actual backend — payment processing, user data, the works.&lt;/p&gt;

&lt;p&gt;I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh repo create their-org/backend &lt;span class="nt"&gt;--public&lt;/span&gt; &lt;span class="nt"&gt;--source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--push&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the &lt;code&gt;--public&lt;/code&gt;. I meant &lt;code&gt;--private&lt;/code&gt;. The CLI didn't ask for confirmation. The repo went live at 14:03.&lt;/p&gt;

&lt;p&gt;We caught it at 14:50. Forty-seven minutes. Here's what happened in that window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minute 0–2: GitHub's Indexers Wake Up
&lt;/h2&gt;

&lt;p&gt;Within seconds, GitHub's search index picked it up. The repo was indexed and searchable by keyword. This matters because a lot of secret-scanning bots don't crawl GitHub directly — they query the search API for patterns like &lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;/code&gt; or &lt;code&gt;Bearer eyJ&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;GitHub's own secret scanning ran almost immediately. We later got two automated emails from AWS and Stripe saying our keys had been auto-revoked because GitHub notified them. That's the only reason this story isn't worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minute 2–10: The Bots Arrive
&lt;/h2&gt;

&lt;p&gt;We checked the repo's traffic graph after the fact. In the first 10 minutes there were &lt;strong&gt;clones from 14 unique IPs&lt;/strong&gt;. None of them were us.&lt;/p&gt;

&lt;p&gt;These are scrapers. They don't read your code — they &lt;code&gt;git clone --mirror&lt;/code&gt;, then run tools like &lt;a href="https://github.com/trufflesecurity/trufflehog" rel="noopener noreferrer"&gt;trufflehog&lt;/a&gt; and &lt;a href="https://github.com/gitleaks/gitleaks" rel="noopener noreferrer"&gt;gitleaks&lt;/a&gt; against the &lt;strong&gt;entire history&lt;/strong&gt;. Not just the current commit.&lt;/p&gt;

&lt;p&gt;That's the part most people miss. You can &lt;code&gt;git rm&lt;/code&gt; a secret and push, but if it was ever committed, it's in the pack files. A scanner will find it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;trufflehog git file://./backend &lt;span class="nt"&gt;--only-verified&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In our case, an old commit from 2022 had a hardcoded Postgres connection string for a now-defunct staging DB. The bot found it. The DB was offline so nothing happened, but the credential is now in someone's database forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minute 10–30: Business Logic Exposure
&lt;/h2&gt;

&lt;p&gt;This is the part nobody talks about. Even if you have zero secrets in your code, leaking the source itself is bad.&lt;/p&gt;

&lt;p&gt;What we exposed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Webhook signature verification logic.&lt;/strong&gt; Anyone could now see exactly how we validated Stripe webhooks and look for bugs in our implementation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting rules.&lt;/strong&gt; The exact thresholds and the redis keys we used.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internal API routes&lt;/strong&gt; that weren't documented anywhere public, including admin endpoints behind a "secret" path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The promo code generation algorithm.&lt;/strong&gt; It was deterministic based on user ID. Oops.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's a sanitized version of what was sitting in &lt;code&gt;utils/promo.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generatePromoCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;campaign&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PROMO_SECRET&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;campaign&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&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;PROMO_SECRET&lt;/code&gt; was in env, fine. But the algorithm being public means an attacker who later gets &lt;em&gt;just&lt;/em&gt; the secret (from a different leak, a former employee, anything) can generate unlimited promo codes for every user.&lt;/p&gt;

&lt;p&gt;Security through obscurity is not security — but losing obscurity does shift your threat model. You should know it shifted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minute 30–47: The Discovery and Panic
&lt;/h2&gt;

&lt;p&gt;A teammate noticed the repo was public when they got a GitHub notification ("Your repo is now visible to..."). The next 17 minutes were chaos. Here's the order we did things in, which in retrospect was mostly right:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Make the repo private immediately.&lt;/strong&gt; Don't delete it yet — you'll want the audit trail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotate every secret in the codebase, even ones not flagged.&lt;/strong&gt; Database passwords, API keys, JWT signing keys, webhook secrets, OAuth client secrets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invalidate all active sessions.&lt;/strong&gt; If your JWT signing key changed, this happens automatically. If you use server-side sessions, flush them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check audit logs&lt;/strong&gt; on AWS, Stripe, GitHub, your DB. Look for unusual API calls in the leak window and the next 24 hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then&lt;/strong&gt; you can delete the repo or scrub history if you want — but assume the code is already cloned.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I Wish We Had
&lt;/h2&gt;

&lt;p&gt;A pre-push hook that screams when you're about to push to a public remote. Something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/sh&lt;/span&gt;
&lt;span class="c"&gt;# .git/hooks/pre-push&lt;/span&gt;
&lt;span class="nv"&gt;remote_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git remote get-url &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;visibility&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gh repo view &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$remote_url&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--json&lt;/span&gt; visibility &lt;span class="nt"&gt;-q&lt;/span&gt; .visibility 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$visibility&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"PUBLIC"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"⚠️  You are pushing to a PUBLIC repo: &lt;/span&gt;&lt;span class="nv"&gt;$remote_url&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Type 'yes-public' to continue:"&lt;/span&gt;
  &lt;span class="nb"&gt;read &lt;/span&gt;confirmation
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$confirmation&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"yes-public"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also worth setting up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub org policies&lt;/strong&gt; that require admin approval to create public repos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-commit secret scanning&lt;/strong&gt; with &lt;code&gt;gitleaks&lt;/code&gt; so secrets never enter history in the first place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Branch protection&lt;/strong&gt; even on main, so a force-push can't accidentally publish private branches.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;p&gt;Leaking source code is not a binary "did you leak secrets or not" event. The real damage is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Historical secrets&lt;/strong&gt; in old commits you forgot about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Business logic&lt;/strong&gt; that informs future attacks even after you rotate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internal endpoints and assumptions&lt;/strong&gt; about what's "hidden."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Assume any public push, even for 60 seconds, has been mirrored. Rotate everything. Audit everything. And put a hook on your machine before you trust yourself with &lt;code&gt;--public&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>discuss</category>
      <category>security</category>
      <category>backup</category>
    </item>
    <item>
      <title>Vibe Coding Is Burning You Out Here's What's Actually Happening to Your Brain</title>
      <dc:creator>Sharjeel Zubair</dc:creator>
      <pubDate>Thu, 23 Apr 2026 16:56:34 +0000</pubDate>
      <link>https://forem.com/sharjeelz/vibe-coding-is-burning-you-out-heres-whats-actually-happening-to-your-brain-4ilc</link>
      <guid>https://forem.com/sharjeelz/vibe-coding-is-burning-you-out-heres-whats-actually-happening-to-your-brain-4ilc</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Vibe coding (riffing with an LLM at high speed) feels productive, but it runs your brain at a sprint pace for marathon hours. The dopamine-fast feedback loop drains glucose, spikes cortisol, and wrecks your sleep. Here's the physiology, plus concrete habits and a few scripts to slow the loop down.&lt;/p&gt;




&lt;h2&gt;
  
  
  The war story
&lt;/h2&gt;

&lt;p&gt;Last month I shipped a side project in a weekend. Fourteen hours on Saturday, twelve on Sunday, mostly prompting Claude and accepting diffs. Monday morning I couldn't remember variable names in my &lt;em&gt;day job&lt;/em&gt; codebase. My resting heart rate was up 8 bpm for a week. I wasn't "tired" — I was cooked.&lt;/p&gt;

&lt;p&gt;I thought I was being lazy. I wasn't. I was experiencing the cost of sustained high-velocity cognitive flow with almost zero rest cycles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why vibe coding hits different
&lt;/h2&gt;

&lt;p&gt;Traditional coding has natural pauses: you read docs, you think, you type, you debug. Those gaps are micro-recovery windows for your prefrontal cortex.&lt;/p&gt;

&lt;p&gt;Vibe coding collapses all of that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prompt → diff → accept/reject → prompt again.&lt;/strong&gt; Cycle time: 10–30 seconds.&lt;/li&gt;
&lt;li&gt;Every accepted diff = small dopamine hit (variable reward schedule, like a slot machine).&lt;/li&gt;
&lt;li&gt;Every rejected diff = micro-frustration + immediate retry.&lt;/li&gt;
&lt;li&gt;No natural "I need to go read the docs" break.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your brain runs on ATP, and the prefrontal cortex is expensive. Sustained decision-making depletes glucose and increases glutamate buildup in the anterior cingulate cortex — which is literally what "mental fatigue" is, per &lt;a href="https://www.cell.com/current-biology/fulltext/S0960-9822(22)01111-3" rel="noopener noreferrer"&gt;Wiehler et al., 2022 (Current Biology)&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Translation: &lt;strong&gt;fast decisions × high volume = neurotoxic byproducts faster than your brain can clear them.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptoms, ranked
&lt;/h2&gt;

&lt;p&gt;From my own logs and conversations with other devs doing a lot of AI-pair work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Eye strain + tension headaches&lt;/strong&gt; (you stopped blinking, you stopped moving)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decision fatigue leaking into non-coding life&lt;/strong&gt; ("what do you want for dinner?" → paralysis)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Poor sleep despite exhaustion&lt;/strong&gt; — elevated cortisol from extended focus&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reduced code comprehension&lt;/strong&gt; when reading non-AI-generated code&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Shallow mastery"&lt;/strong&gt; — you shipped it but couldn't re-derive it&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What to actually do
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Enforce a hard cycle timer
&lt;/h3&gt;

&lt;p&gt;The pomodoro is fine but too long for vibe coding. I use 25-on / 5-off minimum, but for heavy LLM sessions I drop to &lt;strong&gt;20/7&lt;/strong&gt;. Here's a dead-simple terminal timer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# macOS; use notify-send on Linux
&lt;/span&gt;    &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;osascript&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-e&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;display notification &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Code block: 20 min&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BREAK. Stand up. Look at something 20ft away.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The break rule: &lt;strong&gt;leave the chair&lt;/strong&gt;. If you stay seated you will "just finish this one thing" and the break is gone.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The 20-20-20-20 rule (I added one)
&lt;/h3&gt;

&lt;p&gt;Standard: every 20 minutes, look at something 20 feet away for 20 seconds.&lt;/p&gt;

&lt;p&gt;My addition: &lt;strong&gt;20 slow breaths&lt;/strong&gt; before starting a new prompt chain. This resets your sympathetic nervous system from "slot machine" mode.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Separate "generate" and "understand" sessions
&lt;/h3&gt;

&lt;p&gt;The mistake I made: accepting code and immediately prompting for the next thing. Instead, batch it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Session A (30 min): Vibe, generate, accept diffs fast.
Session B (30 min): Read every line. Rename things. Add comments.
                    No AI. No generation.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Session B uses a different cognitive mode (analytic vs. generative) and actually feels restful compared to pure vibe mode. It also catches the bugs you'd ship otherwise.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Pre-commit friction
&lt;/h3&gt;

&lt;p&gt;Add a pre-commit hook that makes you summarize what you did. This forces consolidation into long-term memory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/sh&lt;/span&gt;
&lt;span class="c"&gt;# .git/hooks/prepare-commit-msg&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"# What did this change actually do? (3 bullets)"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"# Why? (1 sentence)"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you can't fill it out, you didn't understand the code. Back to Session B.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Hydrate and fuel for the actual workload
&lt;/h3&gt;

&lt;p&gt;Your brain burns ~20% of your resting calories. When it's sprinting, more. Dehydration of even 2% measurably reduces cognitive performance (&lt;a href="https://pubmed.ncbi.nlm.nih.gov/22855911/" rel="noopener noreferrer"&gt;Adan, 2012&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;My desk rules during vibe sessions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;500ml water within reach, refill every break&lt;/li&gt;
&lt;li&gt;Protein + fat snack every 2 hours (nuts, cheese) — not sugar&lt;/li&gt;
&lt;li&gt;Caffeine cutoff 8 hours before sleep (not 6, not 4 — 8)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  6. Body &amp;gt; mind tricks
&lt;/h3&gt;

&lt;p&gt;The cheapest stress dump I've found: &lt;strong&gt;box breathing between prompts.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;4s inhale → 4s hold → 4s exhale → 4s hold
Repeat 4 times.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sounds stupid. Works. Navy SEALs use it, and it directly lowers cortisol via vagal activation. Do it while the LLM is streaming its response — free time.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Log your sessions
&lt;/h3&gt;

&lt;p&gt;This one surprised me. I started logging vibe sessions:&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;date&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2024-11-08&lt;/span&gt;
&lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3h20m&lt;/span&gt;
&lt;span class="na"&gt;prompts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;~85&lt;/span&gt;
&lt;span class="na"&gt;subjective_fatigue (1-10)&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8&lt;/span&gt;
&lt;span class="na"&gt;quality_of_output (1-10)&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
&lt;span class="na"&gt;sleep_that_night&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;6h, fragmented&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After two weeks the pattern was obvious: &lt;strong&gt;anything over 2.5 hours of continuous vibe coding gave me negative ROI.&lt;/strong&gt; Quality dropped, fatigue spiked, sleep tanked. I now cap it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Vibe coding isn't free velocity. You're borrowing focus from tomorrow to ship today. That's fine occasionally — it's a disaster as a default.&lt;/p&gt;

&lt;p&gt;Treat high-speed AI coding sessions like sprinting, not jogging: short, intense, with real recovery between. Your 40-year-old self will thank you, and honestly, your code next Tuesday will too.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>mentalhealth</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Vibe Coding Vs New Generation of coders</title>
      <dc:creator>Sharjeel Zubair</dc:creator>
      <pubDate>Wed, 22 Apr 2026 08:49:00 +0000</pubDate>
      <link>https://forem.com/sharjeelz/vibe-coding-vs-new-generation-of-coders-cf5</link>
      <guid>https://forem.com/sharjeelz/vibe-coding-vs-new-generation-of-coders-cf5</guid>
      <description>&lt;h1&gt;
  
  
  I Watched a 14-Year-Old Ship a Working App Without Reading a Single Line of Docs
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; The new generation isn't learning to code the way we did — they're learning to &lt;em&gt;direct&lt;/em&gt; code. AI and "vibe coding" have flipped the loop from syntax-first to outcome-first, and it's producing wildly different skill profiles: faster shippers, weaker debuggers, and a new class of problems nobody taught in CS 101.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Moment It Clicked for Me
&lt;/h2&gt;

&lt;p&gt;My nephew wanted a Chrome extension that blurs Instagram Reels after 10 minutes of scrolling. He's 14. He's never written JavaScript.&lt;/p&gt;

&lt;p&gt;Two hours later, he had a working extension. He used Cursor, prompted in plain English, accepted most suggestions, ran into a CORS error, pasted the error back in, and got a fix. Shipped.&lt;/p&gt;

&lt;p&gt;When I asked him what &lt;code&gt;addEventListener&lt;/code&gt; does, he shrugged. "The AI handles that part."&lt;/p&gt;

&lt;p&gt;That's vibe coding in a nutshell — and it's how a huge chunk of Gen Z and Gen Alpha are entering the craft.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "Vibe Coding" Actually Means
&lt;/h2&gt;

&lt;p&gt;The term got popularized by Andrej Karpathy, but the practice is older. You describe what you want. You run it. If it breaks, you paste the error back. You barely look at the code.&lt;/p&gt;

&lt;p&gt;Here's the classic loop a self-taught teen runs today:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Describe the thing in English
2. Accept the generated code
3. Run it
4. Paste the error (if any) back into the chat
5. Repeat until it works
6. Ship
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare that to how most of us learned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Read a tutorial for 3 hours
2. Type code character-by-character
3. Hit a bug
4. Read Stack Overflow for 45 minutes
5. Understand why
6. Fix it
7. Ship eventually
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both produce working software. They produce &lt;em&gt;very&lt;/em&gt; different programmers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What They're Getting Good At
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Systems thinking and product sense, early.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When syntax isn't the bottleneck, kids spend their mental budget on &lt;em&gt;what&lt;/em&gt; to build. I see 16-year-olds arguing about auth flows and rate limiting before they know what a for-loop looks like.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Prompt decomposition.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The good ones have learned that "build me a todo app" gets slop, but this works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Build a todo app with:
- SQLite via better-sqlite3
- Express server on port 3001
- Endpoints: GET /todos, POST /todos, DELETE /todos/:id
- Return JSON, no HTML
- Use prepared statements
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a skill. It's basically writing a spec. The old generation called this "requirements engineering" and charged $200/hr for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Reading code faster than writing it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Since the AI dumps 80 lines in front of them constantly, they learn to &lt;em&gt;skim&lt;/em&gt; code. They pick out what matters. They recognize patterns without memorizing syntax.&lt;/p&gt;

&lt;h2&gt;
  
  
  What They're Losing
&lt;/h2&gt;

&lt;p&gt;This is where it gets uncomfortable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mental models of execution.&lt;/strong&gt; When I ask a vibe coder to trace what happens when you type &lt;code&gt;fetch('/api')&lt;/code&gt; in the browser, I often get silence. They've never needed to know. Until the day they do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Debugging without a copilot.&lt;/strong&gt; Try this test: unplug the AI and give them a bug like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="c1"&gt;# Later...
&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;42&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="c1"&gt;# KeyError sometimes, None sometimes. Why?
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A traditional learner will check types (&lt;code&gt;"42"&lt;/code&gt; vs &lt;code&gt;42&lt;/code&gt;), inspect the data, narrow it down. A pure vibe coder will paste the whole thing into ChatGPT. That works — until the bug is in &lt;em&gt;business logic the AI doesn't know about&lt;/em&gt;, and then they're stuck.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The "why" of abstractions.&lt;/strong&gt; Why is &lt;code&gt;useEffect&lt;/code&gt; the way it is? Why do we have async/await instead of just callbacks? That history carries wisdom. Skipping it means repeating mistakes — I've watched new devs reinvent jQuery-era antipatterns in React, confidently.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Concrete Pedagogy Shift
&lt;/h2&gt;

&lt;p&gt;Good bootcamps and CS programs are adapting. Here's a pattern I've seen work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Week 1: Build something cool with AI. Ship it. Feel the dopamine.
Week 2: Now rebuild ONE component of it by hand. No AI.
Week 3: Break your own code. Fix it without AI.
Week 4: Read the AI's output and annotate what each line does.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This flips the old "fundamentals first, projects later" model. Motivation comes first, mechanics come second. And honestly? It might work better for a lot of brains.&lt;/p&gt;

&lt;h2&gt;
  
  
  The New Baseline Skill: Verification
&lt;/h2&gt;

&lt;p&gt;The single most important skill for a vibe-native coder is &lt;strong&gt;verifying AI output&lt;/strong&gt;. Not writing code — &lt;em&gt;checking&lt;/em&gt; it.&lt;/p&gt;

&lt;p&gt;I now teach juniors this habit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The AI gave me this. Before I trust it, I ask:&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sanitizeInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&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="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;script&amp;gt;/gi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 1. What does this NOT catch?&lt;/span&gt;
&lt;span class="c1"&gt;//    &amp;lt;SCRIPT &amp;gt;, &amp;lt;script src=...&amp;gt;, &amp;lt;img onerror=...&amp;gt;, etc.&lt;/span&gt;
&lt;span class="c1"&gt;// 2. Is there a stdlib or battle-tested lib for this?&lt;/span&gt;
&lt;span class="c1"&gt;//    Yes: DOMPurify. Use that.&lt;/span&gt;
&lt;span class="c1"&gt;// 3. Why did the AI suggest the naive version?&lt;/span&gt;
&lt;span class="c1"&gt;//    Because it pattern-matched the shallow intent.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That three-question habit — &lt;em&gt;what does it miss, is there a better tool, why did it pick this&lt;/em&gt; — is the new fundamentals.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;p&gt;Vibe coding isn't "real coding's" lazy cousin. It's a different entry point producing a different shape of engineer. Faster at shipping, better at product, weaker at fundamentals, totally dependent on tools that didn't exist five years ago.&lt;/p&gt;

&lt;p&gt;The ones who'll thrive aren't the purists refusing AI, or the vibe-only coders who can't debug. They're the kids who can ship fast &lt;em&gt;and&lt;/em&gt; drop into the machine when it matters.&lt;/p&gt;

&lt;p&gt;If you're mentoring someone new right now: don't take the AI away. Teach them to interrogate it.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>discuss</category>
      <category>vibecoding</category>
      <category>career</category>
    </item>
    <item>
      <title>Porting a US Stock Trading App to the Saudi Market -What Actually Broke</title>
      <dc:creator>Sharjeel Zubair</dc:creator>
      <pubDate>Wed, 22 Apr 2026 08:32:34 +0000</pubDate>
      <link>https://forem.com/sharjeelz/porting-a-us-stock-trading-app-to-the-saudi-market-what-actually-broke-26i6</link>
      <guid>https://forem.com/sharjeelz/porting-a-us-stock-trading-app-to-the-saudi-market-what-actually-broke-26i6</guid>
      <description>&lt;p&gt;Last month I forked my US trading platform (Dawul Trader) to build a Saudi equivalent for the Tadawul exchange. I assumed it'd be a branding-and-data-source swap. A weekend, maybe.&lt;/p&gt;

&lt;p&gt;It took longer than that. Here's what the "just change the data feed" plan actually looked like in practice, and the five things I didn't see coming.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;Same as the US version, because I wanted the ports to evolve independently without a shared-package dance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backend:&lt;/strong&gt; Python / FastAPI, Anthropic SDK for AI analysis&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frontend:&lt;/strong&gt; React 19, Vite, Tailwind&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Indicators:&lt;/strong&gt; &lt;code&gt;ta&lt;/code&gt; for RSI/MACD/EMA/VWAP&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data:&lt;/strong&gt; Twelve Data REST + a Laravel MySQL DB (existing Saudi equity data)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Gotcha #1 — Your data vendor probably doesn't cover Saudi
&lt;/h2&gt;

&lt;p&gt;My US version used Alpha Vantage. I was ready to flip an env var and call it done. Alpha Vantage has zero Tadawul coverage. Neither does yfinance in any reliable way.&lt;/p&gt;

&lt;p&gt;Twelve Data does, but you have to pass &lt;code&gt;exchange=SAU&lt;/code&gt; explicitly — otherwise their symbol resolver happily gives you an unrelated ticker from another market. Stock &lt;code&gt;2222&lt;/code&gt; is Aramco on Tadawul and something completely different on a dozen other exchanges.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_td_macd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1day&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_td_get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;macd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;symbol&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exchange&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SAU&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;# &amp;lt;-- critical
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;interval&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fast_period&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;macd_fast&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slow_period&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;macd_slow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;signal_period&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;macd_signal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;outputsize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;format&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JSON&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're building on Twelve Data for Saudi, bake &lt;code&gt;exchange=SAU&lt;/code&gt; into every single endpoint call. I lost an afternoon debugging "correct numbers for the wrong company."&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha #2 — No options market
&lt;/h2&gt;

&lt;p&gt;Tadawul has no equity options. The entire Options Chain page, all the options strategies, the expiration logic — dead code in the Saudi version. I'd built a lot of UI around a feature that simply doesn't exist for these users.&lt;/p&gt;

&lt;p&gt;Lesson: when porting, audit your feature list against &lt;em&gt;what the target market actually trades&lt;/em&gt; before you migrate the code. I could have skipped two days of ripping out dependencies if I'd mapped this first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha #3 — SELL signals in a long-only market
&lt;/h2&gt;

&lt;p&gt;My US screener outputs &lt;code&gt;BUY&lt;/code&gt;, &lt;code&gt;SELL&lt;/code&gt;, and &lt;code&gt;NO TRADE&lt;/code&gt;. Easy call on NYSE — retail users can short with a click.&lt;/p&gt;

&lt;p&gt;Tadawul is effectively long-only for retail. Naked shorting is heavily restricted. So when I initially ported the screener, I considered stripping SELL entirely and labeling bearish setups as &lt;code&gt;NO TRADE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I pushed back on that when a user asked: &lt;em&gt;"why no sells?"&lt;/em&gt; The answer is nuance — a SELL signal isn't useless in a long-only market; it just means something different:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On NYSE: "Consider a short entry."&lt;/li&gt;
&lt;li&gt;On Tadawul: "Exit longs / don't enter here."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I kept the detection logic but reframed the UI copy and made sure the signal was visible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Strategy2.jsx — SELL badge coexists with BUY&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BUY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Badge&lt;/span&gt; &lt;span class="nx"&gt;color&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;green&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;BUY&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/Badge&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELL&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Badge&lt;/span&gt; &lt;span class="nx"&gt;color&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;red&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;SELL&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/Badge&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Badge&lt;/span&gt; &lt;span class="nx"&gt;color&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gray&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;NO&lt;/span&gt; &lt;span class="nx"&gt;TRADE&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/Badge&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The backend mirrors bullish filters into bearish ones:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;buy_ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;span class="n"&gt;sell_ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;use_macd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;macd_line&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="n"&gt;sell_ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;              &lt;span class="c1"&gt;# bullish -&amp;gt; block SELL
&lt;/span&gt;        &lt;span class="n"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MACD bullish&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;macd_line&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="n"&gt;buy_ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;               &lt;span class="c1"&gt;# bearish -&amp;gt; block BUY
&lt;/span&gt;        &lt;span class="n"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MACD bearish&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;buy_ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sell_ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;     &lt;span class="c1"&gt;# near-zero -&amp;gt; no trade
&lt;/span&gt;
&lt;span class="c1"&gt;# Priority: BUY &amp;gt; SELL &amp;gt; NO TRADE
&lt;/span&gt;&lt;span class="n"&gt;signal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BUY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;buy_ok&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sell_ok&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NO TRADE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same code, different semantics at the UX layer. Worth the extra 30 lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha #4 — Numeric tickers + Arabic names + RTL
&lt;/h2&gt;

&lt;p&gt;US tickers are letters (AAPL, MSFT). Saudi tickers are numbers (2222, 1120, 7010). That sounds trivial — it breaks a surprising amount:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Any regex you wrote assuming &lt;code&gt;[A-Z]{1,5}&lt;/code&gt; on ticker input.&lt;/li&gt;
&lt;li&gt;URL params that look nothing like tickers (&lt;code&gt;/stock/2222&lt;/code&gt; reads like a product ID).&lt;/li&gt;
&lt;li&gt;Users recognizing companies. Nobody in Riyadh thinks "2222," they think "أرامكو السعودية."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I wired Arabic names in alongside the numeric code, and let React handle direction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex flex-col"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;symbol&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;stockNames&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;name_ar&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rtl&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fontWeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;stockNames&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;name_ar&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;direction: "rtl"&lt;/code&gt; on just the Arabic span — not the whole page — is the move. Full-page RTL breaks every Tailwind utility I had.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha #5 — The market calendar
&lt;/h2&gt;

&lt;p&gt;Saudi trades &lt;strong&gt;Sunday through Thursday, 10:00–15:00 AST&lt;/strong&gt;. Every "is market open?" helper I'd written assumed weekday = Mon–Fri. Every "next trading day" calculation was wrong. Every cron that ran at US market open fired on Saudi lunch.&lt;/p&gt;

&lt;p&gt;Don't hard-code weekends. Read them from the exchange config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;SAUDI_TRADING_DAYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;# Sun=6, Mon=0, ..., Thu=3 (Python weekday)
&lt;/span&gt;&lt;span class="n"&gt;SAUDI_HOURS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&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="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;15&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  One small UX win
&lt;/h2&gt;

&lt;p&gt;After the SELL work shipped, I got asked a second time: &lt;em&gt;"can I just copy all the BUYs to paste into my broker?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two-line change. The count pill becomes a button; click it, symbols hit the clipboard comma-separated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;copySignalSymbols&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;syms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clipboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;syms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nf"&gt;setCopied&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;setTimeout&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;setCopied&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1500&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;Takes 30 seconds to write and users immediately noticed. Most satisfying kind of feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell past me
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pick your vendor before you write any code.&lt;/strong&gt; Data source constraints cascade everywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit the &lt;em&gt;market's&lt;/em&gt; feature surface, not your app's.&lt;/strong&gt; Options, shorting, ETFs, fractional shares — all vary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't strip signals, reframe them.&lt;/strong&gt; A bearish signal is useful in every market; only the recommended &lt;em&gt;action&lt;/em&gt; changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Localization is not translation.&lt;/strong&gt; Numeric tickers, RTL text, calendar, currency, decimal format — each is its own ticket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship the tiny UX requests.&lt;/strong&gt; Clicking a count to copy symbols is the kind of thing users remember.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're thinking about porting a trading tool to a new market, start with the calendar and the vendor. Everything else is downstream.&lt;/p&gt;

</description>
      <category>react</category>
      <category>webdev</category>
      <category>fintech</category>
      <category>python</category>
    </item>
    <item>
      <title>GDPR Erasure Is Not DELETE FROM users</title>
      <dc:creator>Sharjeel Zubair</dc:creator>
      <pubDate>Wed, 22 Apr 2026 08:25:17 +0000</pubDate>
      <link>https://forem.com/sharjeelz/gdpr-erasure-is-not-delete-from-users-4400</link>
      <guid>https://forem.com/sharjeelz/gdpr-erasure-is-not-delete-from-users-4400</guid>
      <description>&lt;p&gt;When a parent on our school platform submits a deletion request, an administrator approves it, and our server runs one function — &lt;code&gt;anonymiseContact($contactId)&lt;/code&gt;. That function took five attempts to get right.&lt;/p&gt;

&lt;p&gt;This post is about what makes Article 17 of the GDPR (the "right to erasure") genuinely difficult to implement, the decision tree we eventually settled on, and the specific failure modes we hit along the way. The code is Laravel and Postgres, but the decisions are universal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive implementation
&lt;/h2&gt;

&lt;p&gt;The obvious first draft of "delete a user's personal data" is a single SQL statement:&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="nc"&gt;RosterContact&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&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="nv"&gt;$contactId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are three separate reasons this will fail or fail-you-later. Each points at a different design decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: the NOT NULL foreign key trap
&lt;/h2&gt;

&lt;p&gt;Our &lt;code&gt;students&lt;/code&gt; table has this column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;unsignedBigInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'roster_contact_id'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// NOT NULL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Calling &lt;code&gt;Student::where('roster_contact_id', $contactId)-&amp;gt;update(['roster_contact_id' =&amp;gt; null])&lt;/code&gt; before deleting the contact produces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SQLSTATE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;23502&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="k"&gt;Not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="n"&gt;violation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="n"&gt;ERROR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;column&lt;/span&gt;
&lt;span class="nv"&gt;"roster_contact_id"&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="n"&gt;relation&lt;/span&gt; &lt;span class="nv"&gt;"students"&lt;/span&gt; &lt;span class="n"&gt;violates&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;constraint&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every table that references the contact has the same problem: &lt;code&gt;issues&lt;/code&gt;, &lt;code&gt;leave_requests&lt;/code&gt;, &lt;code&gt;consent_responses&lt;/code&gt;, &lt;code&gt;meeting_bookings&lt;/code&gt;, &lt;code&gt;students&lt;/code&gt;. A naive cascading delete would wipe all of them — which is wrong, because the school &lt;em&gt;owns&lt;/em&gt; those records and is legally required to keep them (attendance history, complaint records, consent audit trail).&lt;/p&gt;

&lt;p&gt;The fix is to make &lt;code&gt;roster_contact_id&lt;/code&gt; nullable on every downstream table:&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="nc"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'students'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Blueprint&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;unsignedBigInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'roster_contact_id'&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;nullable&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;change&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// ... same for leave_requests, consent_responses, meeting_bookings&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only then can you null the link while keeping the row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: the four-way decision
&lt;/h2&gt;

&lt;p&gt;Once the FKs are nullable, every table the user touched needs a deliberate decision. There are four possible outcomes, not two:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;th&gt;When to use it&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hard delete&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Data has no value after erasure and belongs exclusively to the user&lt;/td&gt;
&lt;td&gt;Access codes, push-notification tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Anonymise (keep the row, strip PII)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Data has operational or audit value but shouldn't identify the user&lt;/td&gt;
&lt;td&gt;The contact record itself, messages authored by the user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Detach (null the FK, keep the row)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Data belongs to a different party (the school, the platform) but was linked to the user&lt;/td&gt;
&lt;td&gt;Issues, leave requests, consent responses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Retain (untouched)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Data was never directly identifying&lt;/td&gt;
&lt;td&gt;CSAT ratings linked via issue, aggregate metrics&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For our platform, the decision matrix looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Access codes           → hard delete
Push tokens, device ID → null out (PII)
Contact record         → anonymise (name → "Deleted Contact", PII nulled)
Issue messages         → anonymise (null author_id, replace meta.actor_name)
Activity log entries   → anonymise (scrub contact_name in JSON payload)
Issues, leaves,
consents, bookings     → detach (null roster_contact_id, keep row)
Students               → detach (school owns the student record)
CSAT responses         → retain (never carried direct PII)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matrix is worth publishing for your own users. Under Article 15 (right of access) and Article 30 (records of processing), a data subject can ask what happens when they exercise erasure. A vague "we delete your data" is not a satisfactory answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The implementation
&lt;/h2&gt;

&lt;p&gt;Here is the core of &lt;code&gt;anonymiseContact()&lt;/code&gt;, simplified. Note the order of operations and the transaction boundary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;anonymiseContact&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;$contactId&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;$tenantId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tenant&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="nv"&gt;$contact&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RosterContact&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&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="nv"&gt;$contactId&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;first&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;$contact&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$actor&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;auth&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;user&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// the admin who approved&lt;/span&gt;
    &lt;span class="nv"&gt;$school&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;School&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'tenant_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$tenantId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// 1. Collect attachment file references BEFORE deleting rows&lt;/span&gt;
    &lt;span class="nv"&gt;$attachmentFiles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;IssueAttachment&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereHas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'issue'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$q&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;$q&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'roster_contact_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$contactId&lt;/span&gt;&lt;span class="p"&gt;)&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="s1"&gt;'disk'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'path'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// 2. Auto-close open issues + unassign (housekeeping — see below)&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;autoCloseOpenIssues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$contactId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$actor&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// 3. Cancel future meeting bookings&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;cancelFutureBookings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$contactId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$actor&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// 4. Core anonymisation inside a transaction&lt;/span&gt;
    &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$contactId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$contact&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Hard-delete access codes (no value after erasure)&lt;/span&gt;
        &lt;span class="nc"&gt;AccessCode&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'roster_contact_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$contactId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="c1"&gt;// Anonymise messages authored by this contact&lt;/span&gt;
        &lt;span class="nc"&gt;IssueMessage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'author_type'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;RosterContact&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'author_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$contactId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;IssueMessage&lt;/span&gt; &lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$meta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;meta&lt;/span&gt; &lt;span class="o"&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="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'actor_name'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nv"&gt;$meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'actor_name'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Deleted Contact'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="nv"&gt;$m&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;'author_id'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="s1"&gt;'author_type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="s1"&gt;'meta'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$meta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;]);&lt;/span&gt;
            &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="c1"&gt;// Scrub contact name from activity log payloads&lt;/span&gt;
        &lt;span class="nc"&gt;IssueActivity&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'issue_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Issue&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'roster_contact_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$contactId&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;pluck&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;IssueActivity&lt;/span&gt; &lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&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="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'contact_name'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'contact_name'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Deleted Contact'&lt;/span&gt;&lt;span class="p"&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;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;'data'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="c1"&gt;// Detach — null the FK on downstream records&lt;/span&gt;
        &lt;span class="nc"&gt;Issue&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'roster_contact_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$contactId&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;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'roster_contact_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="nc"&gt;LeaveRequest&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'roster_contact_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$contactId&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;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'roster_contact_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="nc"&gt;ConsentResponse&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'roster_contact_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$contactId&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;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'roster_contact_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="nc"&gt;MeetingBooking&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'roster_contact_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$contactId&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;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'roster_contact_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="nc"&gt;Student&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'roster_contact_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$contactId&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;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'roster_contact_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="c1"&gt;// Finally, anonymise the contact itself&lt;/span&gt;
        &lt;span class="nv"&gt;$contact&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;'name'&lt;/span&gt;            &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Deleted Contact'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'email'&lt;/span&gt;           &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'phone'&lt;/span&gt;           &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'external_id'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'expo_push_token'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'device_platform'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'meta'&lt;/span&gt;            &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'deactivated_at'&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="s1"&gt;'revoke_reason'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'erasure_request'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// 5. Delete attachment files from disk AFTER the DB commits&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;$attachmentFiles&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'disk'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'path'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\Throwable&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="c1"&gt;// Orphaned file is safer than a ghost DB row. Log and move on.&lt;/span&gt;
            &lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Attachment delete failed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are four non-obvious decisions in this code.&lt;/p&gt;

&lt;h3&gt;
  
  
  File cleanup happens outside the transaction
&lt;/h3&gt;

&lt;p&gt;Deleting a file from disk is not transactional. If the DB commits and the &lt;code&gt;Storage::delete()&lt;/code&gt; call fails, you have an orphaned file — annoying but recoverable. If the DB is still mid-transaction and the file deletion succeeds but the DB then rolls back, you have a ghost DB row pointing at a non-existent file — user-visible errors and harder to clean up.&lt;/p&gt;

&lt;p&gt;We favour the first failure mode. Orphaned files can be swept out-of-band; ghost rows break UI.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attachment paths are collected before the transaction
&lt;/h3&gt;

&lt;p&gt;Inside the transaction we run the DELETE on &lt;code&gt;issue_attachments&lt;/code&gt;. That removes the rows we would otherwise query for their disk paths. Collecting paths &lt;em&gt;before&lt;/em&gt; the transaction runs means we still have the list when the DB part succeeds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Auto-housekeeping is separate from anonymisation
&lt;/h3&gt;

&lt;p&gt;When a contact is erased, their open issues should close, their future meeting bookings should cancel, and their staff assignments should release. We do this &lt;em&gt;before&lt;/em&gt; the anonymisation transaction, attributed to the approving admin, so each auto-close emits its own activity-log entry with a clear actor and a &lt;code&gt;close_reason: contact_erased&lt;/code&gt; flag.&lt;/p&gt;

&lt;p&gt;If we did it inside the anonymisation, the actor on those closures would end up as &lt;code&gt;null&lt;/code&gt; or &lt;code&gt;"Deleted Contact"&lt;/code&gt;, which is an audit trail that says "something happened to an issue but no one did it." That's the opposite of what you want in a compliance artefact.&lt;/p&gt;

&lt;h3&gt;
  
  
  Retry semantics matter
&lt;/h3&gt;

&lt;p&gt;Our first deployment of &lt;code&gt;anonymiseContact()&lt;/code&gt; crashed on the NOT NULL FK described earlier. The DB transaction rolled back cleanly, but our &lt;code&gt;DataPrivacyRequest&lt;/code&gt; row ended up flagged &lt;code&gt;failed&lt;/code&gt; with the error message stored.&lt;/p&gt;

&lt;p&gt;We added two flags to the admin UI:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Failed requests show a "Retry &amp;amp; execute" button instead of "Approve &amp;amp; execute"&lt;/li&gt;
&lt;li&gt;The approve action accepts both &lt;code&gt;pending&lt;/code&gt; and &lt;code&gt;failed&lt;/code&gt; statuses&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After fixing the schema (making FKs nullable), the admin clicked Retry and the request completed. Without that retry affordance, every fix would have required manual DB editing or asking the parent to submit a fresh request — both are terrible UX for the one operation that must feel exact.&lt;/p&gt;

&lt;h2&gt;
  
  
  What stays forever
&lt;/h2&gt;

&lt;p&gt;One counterintuitive part of GDPR erasure: the record that an erasure happened must itself be kept.&lt;/p&gt;

&lt;p&gt;We keep three types of records indefinitely:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The &lt;code&gt;data_privacy_requests&lt;/code&gt; row — with the contact's name and email captured at request time. This is the compliance evidence.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;privacy_erasure&lt;/code&gt; activity-log entry — summary of what was erased (issues closed, bookings cancelled) attributed to the approving admin.&lt;/li&gt;
&lt;li&gt;Backup snapshots that predate the erasure — these age out on their own 90-day rotation, but until they do, the PII is still in them. That's worth disclosing in your DPA.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Supervisory authorities under Article 33 expect to see records of requests and their handling. A user asking "when did you delete me and who approved it?" is a legitimate question with a legitimate answer: "on this date, by this admin, here's the evidence."&lt;/p&gt;

&lt;h2&gt;
  
  
  Publishing the decision tree
&lt;/h2&gt;

&lt;p&gt;Every word of the matrix above is now in our user-facing Privacy Policy, under a subsection titled "What happens when your erasure request is approved." Publishing it serves three purposes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Users exercising the right get a realistic expectation&lt;/li&gt;
&lt;li&gt;Auditors reading the policy see the engineering has substance&lt;/li&gt;
&lt;li&gt;It forces internal clarity — you cannot publish what you haven't decided&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are about to implement Article 17, do not start with the code. Start with the decision matrix, write it in user-facing language first, and let the code follow. The five attempts we took were largely about failing to do this in the right order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;GDPR erasure is an engineering decision with four outcomes per data type, not a binary one. The implementation needs nullable foreign keys, a deliberate order of operations, transaction boundaries that correctly separate DB and storage, separate housekeeping for operational side-effects, retry semantics for failure recovery, and an indefinite audit trail of the erasure itself. The naive &lt;code&gt;DELETE FROM users&lt;/code&gt; produces the wrong legal outcome, the wrong operational outcome, and the wrong audit outcome at the same time.&lt;/p&gt;

&lt;p&gt;Write the matrix first. Then write the code.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>gdpr</category>
      <category>database</category>
      <category>security</category>
    </item>
    <item>
      <title>A Dashboard for developers!

https://spiffy-palmier-f0c5af.netlify.app/
#health #focus</title>
      <dc:creator>Sharjeel Zubair</dc:creator>
      <pubDate>Tue, 14 Apr 2026 11:09:04 +0000</pubDate>
      <link>https://forem.com/sharjeelz/a-dashboard-for-developershttpsspiffy-palmier-f0c5afnetlifyapphealth-focus-3l0k</link>
      <guid>https://forem.com/sharjeelz/a-dashboard-for-developershttpsspiffy-palmier-f0c5afnetlifyapphealth-focus-3l0k</guid>
      <description>&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://spiffy-palmier-f0c5af.netlify.app/" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;spiffy-palmier-f0c5af.netlify.app&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


</description>
    </item>
    <item>
      <title>I Built a Multi-Tenant School Helpdesk in Laravel — Here's the Full Stack</title>
      <dc:creator>Sharjeel Zubair</dc:creator>
      <pubDate>Tue, 14 Apr 2026 10:28:12 +0000</pubDate>
      <link>https://forem.com/sharjeelz/i-built-a-multi-tenant-school-helpdesk-in-laravel-heres-the-full-stack-3nj1</link>
      <guid>https://forem.com/sharjeelz/i-built-a-multi-tenant-school-helpdesk-in-laravel-heres-the-full-stack-3nj1</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;I open-sourced a production-grade, multi-tenant SaaS last week. Here's a tour of what's inside — and every non-obvious architecture decision along the way.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Meet Schoolytics
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/sharjeelz/eliflammeem-git" rel="noopener noreferrer"&gt;&lt;strong&gt;Schoolytics&lt;/strong&gt;&lt;/a&gt; is an open-source helpdesk for schools. One platform runs an entire district — each school is isolated by subdomain, runs its own branded portal, and has its own users, issues, and CSAT data.&lt;/p&gt;

&lt;p&gt;Think &lt;strong&gt;Zendesk meets Linear meets a parent-teacher app&lt;/strong&gt; — MIT-licensed, self-hostable, and free forever.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🏫 &lt;strong&gt;Multi-tenant&lt;/strong&gt; — one deploy, unlimited schools, subdomain-isolated&lt;/li&gt;
&lt;li&gt;🔑 &lt;strong&gt;Passwordless parent portal&lt;/strong&gt; — one-time access codes, no app, no account&lt;/li&gt;
&lt;li&gt;🤖 &lt;strong&gt;AI triage&lt;/strong&gt; — Python sentiment microservice scores every issue on submission&lt;/li&gt;
&lt;li&gt;🧑‍💼 &lt;strong&gt;Three-tier RBAC&lt;/strong&gt; — admin / branch manager / staff, enforced in queries AND policies&lt;/li&gt;
&lt;li&gt;📧 &lt;strong&gt;Full email stack&lt;/strong&gt; — transactional mails, in-app notifications, CSAT surveys&lt;/li&gt;
&lt;li&gt;🎛 &lt;strong&gt;Nova super-admin&lt;/strong&gt; — provision a new school (tenant + domain + admin + 10 categories + demo data) with ONE click&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Framework&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Laravel 12 / PHP 8.2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fastest iteration, Nova, first-party queue/mail/auth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;PostgreSQL 16&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Row-level multi-tenancy, case-insensitive &lt;code&gt;ilike&lt;/code&gt;, check constraints&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-tenancy&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;stancl/tenancy v3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Battle-tested, subdomain-based, row-level scoping&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RBAC&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Spatie Permission (teams mode)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Team-scoped permissions = tenant-scoped for free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Super-admin&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Laravel Nova&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Built-in resource CRUD, custom actions for provisioning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frontend&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Blade + Alpine + Tailwind + ECharts&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No SPA overhead, ships fast, renders on the server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Queue&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Database driver (dev), Redis/SQS (prod)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One less moving piece locally&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;FastAPI (Python)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Isolated, swappable, doesn't bloat Laravel&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The interesting decisions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Two auth guards, zero overlap
&lt;/h3&gt;

&lt;p&gt;Most multi-tenant apps put super-admins in the same &lt;code&gt;users&lt;/code&gt; table with a &lt;code&gt;is_superadmin&lt;/code&gt; flag. That's a mistake the first time a bug lets a tenant query return a central user.&lt;/p&gt;

&lt;p&gt;Schoolytics has two completely separate models:&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;// Guard: central → entry: /nova&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CentralUser&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Authenticatable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* table: central_users */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Guard: web → entry: /admin/login (tenant subdomain)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Authenticatable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;BelongsToTenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HasRoles&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// table: users (row-level tenant_id)&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;CentralUser&lt;/code&gt; can't have Spatie roles (teams mode requires a tenant context), so the &lt;code&gt;IssuePolicy&lt;/code&gt; has a &lt;code&gt;before()&lt;/code&gt; hook:&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;before&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;?Authenticatable&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;?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="nv"&gt;$user&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;CentralUser&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// full access&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="c1"&gt;// fall through to normal policy methods&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two guards. Zero cross-contamination. Superadmin can debug anything without ever needing a tenant role.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Row-level multi-tenancy, enforced twice
&lt;/h3&gt;

&lt;p&gt;Every tenant-scoped model uses a &lt;code&gt;BelongsToTenant&lt;/code&gt; trait that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Adds a global scope filtering &lt;code&gt;WHERE tenant_id = tenant('id')&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Auto-fills &lt;code&gt;tenant_id&lt;/code&gt; on &lt;code&gt;creating&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But I don't trust global scopes alone. For sensitive actions I also do an explicit 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="nf"&gt;abort_unless&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$issue&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nf"&gt;tenant&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="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Defense in depth.&lt;/strong&gt; If someone ever calls &lt;code&gt;withoutGlobalScopes()&lt;/code&gt; by accident, the explicit check still catches it.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Passwordless parent flow
&lt;/h3&gt;

&lt;p&gt;Parents aren't technical. They don't want accounts. They lose passwords. So:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Admin imports roster contacts (CSV/Excel)&lt;/li&gt;
&lt;li&gt;System generates an &lt;code&gt;AccessCode&lt;/code&gt;, emails/SMSes it&lt;/li&gt;
&lt;li&gt;Parent visits &lt;code&gt;schoola.domain.com&lt;/code&gt;, enters code, submits their issue&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only one open issue per contact at a time&lt;/strong&gt; (enforced by a non-closed-issue lookup)&lt;/li&gt;
&lt;li&gt;On issue close, &lt;code&gt;access_code.used_at&lt;/code&gt; is reset — parent can submit again with the same code&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The UX is identical to a "magic link" but synchronous and auditable.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Role-scoped queries in ONE place
&lt;/h3&gt;

&lt;p&gt;Three roles see three different slices:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;admin&lt;/strong&gt; — every issue in the tenant&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;branch_manager&lt;/strong&gt; — issues in branches they manage&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;staff&lt;/strong&gt; — only issues assigned to them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One query scope handles all of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;scopeVisibleTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Builder&lt;/span&gt; &lt;span class="nv"&gt;$q&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;Builder&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'branch_manager'&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;$q&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'branch_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;branches&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;pluck&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="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'assigned_user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every &lt;code&gt;Issue::query()&lt;/code&gt; in every controller calls &lt;code&gt;-&amp;gt;visibleTo(auth()-&amp;gt;user())&lt;/code&gt;. Forget it once, and the policy still blocks the action. Two layers.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. AI triage via a separate microservice
&lt;/h3&gt;

&lt;p&gt;When a parent submits an issue, a queued listener POSTs to a FastAPI service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Laravel  ──event──► PerformAiAnalysis (queued)  ──HTTP──►  Python FastAPI
                                                               │
                                                               ▼
                                                  {sentiment, category, confidence}
                                                               │
                                                               ▼
                                                  IssueAiAnalysis row
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why separate? Because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Python's ML libraries are in Python, not PHP&lt;/li&gt;
&lt;li&gt;I can swap the model (fine-tune later) without touching Laravel&lt;/li&gt;
&lt;li&gt;The HTTP boundary forces me to think about failure modes (timeouts, retries, circuit breakers)&lt;/li&gt;
&lt;li&gt;The sentiment service is stateless — horizontally scalable on its own&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  6. Nova actions that actually save time
&lt;/h3&gt;

&lt;p&gt;Instead of a 5-step manual tenant setup, &lt;code&gt;ProvisionTenant&lt;/code&gt; does it all:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create &lt;code&gt;Tenant&lt;/code&gt; row (UUID)&lt;/li&gt;
&lt;li&gt;Create &lt;code&gt;Domain&lt;/code&gt; row (subdomain)&lt;/li&gt;
&lt;li&gt;Switch into tenant context, run tenant migrations&lt;/li&gt;
&lt;li&gt;Seed default roles (admin, branch_manager, staff)&lt;/li&gt;
&lt;li&gt;Seed 10 default issue categories&lt;/li&gt;
&lt;li&gt;Create &lt;code&gt;School&lt;/code&gt; + default &lt;code&gt;Branch&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Create first admin &lt;code&gt;User&lt;/code&gt; with random password&lt;/li&gt;
&lt;li&gt;Email the admin their credentials via &lt;code&gt;TenantProvisionedMail&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One click. Thirty seconds. A new school is live.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;GenerateDemoData&lt;/code&gt; is the cooler one — it seeds a tenant with realistic data matched to the 10 default categories (e.g., &lt;code&gt;Transport&lt;/code&gt; gets "Bus late", &lt;code&gt;Academics&lt;/code&gt; gets "Math homework concern"), creates branch managers, staff assigned to categories, parents and teachers each with an open issue. You can demo the product to a school in 60 seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. The queue + tenancy trap
&lt;/h3&gt;

&lt;p&gt;This one bit me hard enough that it got its own &lt;a href="https://dev.to/sharjeelz/the-laravel-queue-multi-tenancy-trap"&gt;postmortem post&lt;/a&gt;. TL;DR: &lt;strong&gt;never put a tenant-scoped Eloquent model in a queued job's constructor&lt;/strong&gt;. Pass scalars, call &lt;code&gt;tenancy()-&amp;gt;initialize()&lt;/code&gt; in &lt;code&gt;handle()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in the repo
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app/
  Http/Controllers/          → tenant + public portal controllers
  Models/                    → User, Issue, RosterContact, AccessCode, ...
  Policies/                  → IssuePolicy, RosterContactPolicy, ...
  Nova/                      → Tenant, Domain, CentralUser resources + actions
  Jobs/                      → AnalyzeIssueSentiment, LogActivityJob
  Listeners/                 → PerformAiAnalysis
  Mail/                      → 6 transactional mailables
database/
  migrations/                → central schema
  migrations/tenant/         → per-tenant schema
  seeders/                   → roles, default categories, demo data
routes/
  web.php                    → /nova + central admin
  tenant.php                 → /admin + public portal (per-tenant)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;~30 migrations, ~40 models, ~20 controllers, ~15 policies, full Nova suite.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently next time
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start with Redis for queue&lt;/strong&gt; — the database driver was fine until demo day, when AI jobs stacked up&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the tenancy feature test FIRST&lt;/strong&gt; — the one that actually boots a queue worker with no tenant context. Would have caught the queue trap in minutes instead of hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick Livewire over Blade+Alpine for the admin&lt;/strong&gt; — the filter-form dance gets old; Livewire would halve the code&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use Inertia for the parent portal&lt;/strong&gt; — it's the one place where a SPA-ish feel would help UX&lt;/li&gt;
&lt;/ul&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/sharjeelz/eliflammeem-git.git
&lt;span class="nb"&gt;cd &lt;/span&gt;eliflammeem-git
composer &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;install
cp&lt;/span&gt; .env.example .env &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; php artisan key:generate
php artisan migrate &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; php artisan db:seed
composer run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then open &lt;code&gt;http://central.lvh.me:8000/nova&lt;/code&gt;. Provision a tenant. Watch the whole thing come alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Contribute
&lt;/h2&gt;

&lt;p&gt;⭐ the repo if this saved you a weekend. PRs welcome — the roadmap is on the README:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Arabic + RTL UI&lt;/li&gt;
&lt;li&gt;WhatsApp Business API for access-code delivery&lt;/li&gt;
&lt;li&gt;SSO for staff&lt;/li&gt;
&lt;li&gt;Per-tenant custom branding&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;🔗 &lt;strong&gt;&lt;a href="https://github.com/sharjeelz/eliflammeem-git" rel="noopener noreferrer"&gt;github.com/sharjeelz/eliflammeem-git&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What would you have done differently? Drop a comment — genuinely want to hear it.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>opensource</category>
      <category>saas</category>
      <category>php</category>
    </item>
    <item>
      <title>The Laravel Queue + Multi-Tenancy Trap That Cost Me 3 Hours</title>
      <dc:creator>Sharjeel Zubair</dc:creator>
      <pubDate>Tue, 14 Apr 2026 10:15:56 +0000</pubDate>
      <link>https://forem.com/sharjeelz/the-laravel-queue-multi-tenancy-trap-that-cost-me-3-hours-3c3d</link>
      <guid>https://forem.com/sharjeelz/the-laravel-queue-multi-tenancy-trap-that-cost-me-3-hours-3c3d</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;A postmortem on a bug that passed all my unit tests, all my feature tests, and every manual smoke test — then blew up the first time a real user clicked a button in production.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I'm building &lt;a href="https://eliflammeem.com" rel="noopener noreferrer"&gt;Schoolytics&lt;/a&gt;, an open-source multi-tenant helpdesk for schools. Multi-tenancy is row-level: one Postgres database, every tenant-scoped table has a &lt;code&gt;tenant_id&lt;/code&gt; column, and every tenant model uses a &lt;code&gt;BelongsToTenant&lt;/code&gt; trait that adds a global scope:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;trait&lt;/span&gt; &lt;span class="nc"&gt;BelongsToTenant&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;bootBelongsToTenant&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;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;addGlobalScope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'tenant'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Builder&lt;/span&gt; &lt;span class="nv"&gt;$q&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;$tenantId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tenant&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="nv"&gt;$q&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getModel&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;getTable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'.tenant_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$tenantId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;creating&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$model&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$model&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="nf"&gt;tenant&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;Simple, effective. Every query a tenant's code makes is automatically scoped. Forget &lt;code&gt;tenant_id&lt;/code&gt;? You physically cannot leak another tenant's data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The feature
&lt;/h2&gt;

&lt;p&gt;When a parent submits an issue through the public portal, we fire an &lt;code&gt;IssueCreated&lt;/code&gt; event. A queued listener calls a Python microservice to analyze sentiment, then writes the result to an &lt;code&gt;issue_ai_analysis&lt;/code&gt; row. Straightforward event → queued listener → DB 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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;IssueCreated&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;Issue&lt;/span&gt; &lt;span class="nv"&gt;$issue&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PerformAiAnalysis&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;InteractsWithQueue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;SerializesModels&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;IssueCreated&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.ai.url'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'text'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;issue&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&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;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nc"&gt;IssueAiAnalysis&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;updateOrCreate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'issue_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;issue&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'sentiment'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$score&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'label'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'confidence'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$score&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'confidence'&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;Green tests. Works in tinker. Ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  The crash
&lt;/h2&gt;

&lt;p&gt;First real submission in production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Illuminate\Database\Eloquent\ModelNotFoundException
No query results for model [App\Models\Issue].
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the issue &lt;strong&gt;exists&lt;/strong&gt;. I can &lt;code&gt;SELECT * FROM issues WHERE id = 189&lt;/code&gt; and see it. The listener is being called with an event whose payload clearly references issue 189 — and then Laravel throws &lt;code&gt;ModelNotFoundException&lt;/code&gt; trying to rehydrate it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap
&lt;/h2&gt;

&lt;p&gt;Here's the lifecycle of a queued listener:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Controller fires &lt;code&gt;event(new IssueCreated($issue))&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Laravel sees the listener is &lt;code&gt;ShouldQueue&lt;/code&gt;, serializes the event via &lt;code&gt;SerializesModels&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The event is &lt;strong&gt;not&lt;/strong&gt; serialized as the full &lt;code&gt;Issue&lt;/code&gt; object — just &lt;code&gt;App\Models\Issue&lt;/code&gt; + the primary key (&lt;code&gt;189&lt;/code&gt;). This is the whole point of &lt;code&gt;SerializesModels&lt;/code&gt;; it keeps payloads tiny and always fresh.&lt;/li&gt;
&lt;li&gt;Worker boots, pulls the job, and calls &lt;code&gt;(new Issue)-&amp;gt;newQueryForRestoration(189)-&amp;gt;firstOrFail()&lt;/code&gt; to rebuild the model.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 4 is where it dies. The worker process has &lt;strong&gt;no tenant context yet&lt;/strong&gt; — it just started. Which means &lt;code&gt;tenant('id')&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt;. Which means &lt;code&gt;BelongsToTenant&lt;/code&gt;'s global scope generates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;issues&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;189&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;   &lt;span class="c1"&gt;-- 💀&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No rows. &lt;code&gt;ModelNotFoundException&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The kicker: this never fails in sync mode (there's no serialization round-trip), and tests typically use &lt;code&gt;Queue::fake()&lt;/code&gt; or sync drivers. The bug is &lt;strong&gt;invisible&lt;/strong&gt; until you run a real queue worker against a real tenant request.&lt;/p&gt;

&lt;p&gt;Laravel's &lt;code&gt;stancl/tenancy&lt;/code&gt; does ship a &lt;code&gt;QueueTenancyBootstrapper&lt;/code&gt; that restores tenant context on the worker — but it fires &lt;strong&gt;after&lt;/strong&gt; &lt;code&gt;SerializesModels::restoreModel()&lt;/code&gt; runs. Too late. The model is already dead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Never put a tenant-scoped Eloquent model directly into a queued event or job. Store scalars, and initialize tenancy yourself inside &lt;code&gt;handle()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;IssueCreated&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;    &lt;span class="nv"&gt;$issueId&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;readonly&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PerformAiAnalysis&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;IssueCreated&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Tenant model itself is NOT tenant-scoped — safe to find()&lt;/span&gt;
        &lt;span class="nv"&gt;$tenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;\App\Models\Tenant&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;tenancy&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;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tenant&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;$issue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Issue&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;findOrFail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;issueId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

            &lt;span class="nv"&gt;$score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.ai.url'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'text'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$issue&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&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;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

            &lt;span class="nc"&gt;IssueAiAnalysis&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;updateOrCreate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'issue_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$issue&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'sentiment'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$score&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'label'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'confidence'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$score&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'confidence'&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;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;tenancy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;end&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;And dispatch it with plain values:&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="nf"&gt;event&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;IssueCreated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;issueId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="nv"&gt;$issue&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;tenant&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three rules I now enforce in code review:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Queued events and jobs store scalars only.&lt;/strong&gt; No Eloquent models in constructor signatures.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Every &lt;code&gt;handle()&lt;/code&gt; method that touches tenant data calls &lt;code&gt;tenancy()-&amp;gt;initialize()&lt;/code&gt; first and &lt;code&gt;tenancy()-&amp;gt;end()&lt;/code&gt; in &lt;code&gt;finally&lt;/code&gt;.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;Tenant&lt;/code&gt; model itself must never use &lt;code&gt;BelongsToTenant&lt;/code&gt;&lt;/strong&gt; (otherwise you can't look it up without already having tenant context — chicken-and-egg).&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;SerializesModels&lt;/code&gt; is still the right default
&lt;/h2&gt;

&lt;p&gt;The trap is real, but the trait exists for good reasons: tiny payloads, always-fresh data, no stale-attribute bugs. The fix isn't to abandon it — it's to recognize that &lt;strong&gt;the trait assumes a single global database context&lt;/strong&gt;, which breaks the moment you add a tenant dimension.&lt;/p&gt;

&lt;p&gt;If you're on single-tenant Laravel, keep passing models. If you're on multi-tenant Laravel with row-level isolation, scalars + manual &lt;code&gt;tenancy()-&amp;gt;initialize()&lt;/code&gt; are your friend.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I caught it for good
&lt;/h2&gt;

&lt;p&gt;I added a simple feature test that actually runs the queue:&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'processes queued listeners with correct tenant context'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$tenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Tenant&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&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;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;tenancy&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;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tenant&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;Queue&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sync'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;...&lt;/span&gt; &lt;span class="c1"&gt;// won't catch it&lt;/span&gt;
    &lt;span class="c1"&gt;// Use actual database queue driver:&lt;/span&gt;
    &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'queue.default'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'database'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$issue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Issue&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&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;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;event&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;IssueCreated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;issueId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$issue&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$tenant&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="nf"&gt;tenancy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// simulate worker boot with no context&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;artisan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'queue:work --once --stop-when-empty'&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;assertExitCode&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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;IssueAiAnalysis&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withoutGlobalScopes&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'issue_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$issue&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeTrue&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;Ending tenancy before the worker runs is the critical line — it reproduces what Supervisor does in production.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; If you're using &lt;code&gt;stancl/tenancy&lt;/code&gt; with row-level isolation and queued events/jobs, never put a tenant-scoped Eloquent model in a queued payload. Pass the ID + tenant ID as scalars, call &lt;code&gt;tenancy()-&amp;gt;initialize()&lt;/code&gt; in &lt;code&gt;handle()&lt;/code&gt;. Your tests won't catch this — only a real queue worker will.&lt;/p&gt;

&lt;p&gt;Source code: &lt;a href="https://github.com/yourusername/schoolytics" rel="noopener noreferrer"&gt;https://github.com/sharjeelz/eliflammeem-git&lt;/a&gt; — the pattern lives in &lt;code&gt;app/Listeners/PerformAiAnalysis.php&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If this saved you 3 hours, drop a ⭐ on the repo. If you've hit it before, I'd love to hear your fix in the comments.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>database</category>
      <category>laravel</category>
      <category>php</category>
    </item>
  </channel>
</rss>
