<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sharjeel Zubair</title>
    <description>The latest articles on DEV Community by Sharjeel Zubair (@sharjeelz).</description>
    <link>https://dev.to/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>DEV Community: Sharjeel Zubair</title>
      <link>https://dev.to/sharjeelz</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sharjeelz"/>
    <language>en</language>
    <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://dev.to/sharjeelz/a-dashboard-for-developershttpsspiffy-palmier-f0c5afnetlifyapphealth-focus-3l0k</link>
      <guid>https://dev.to/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://dev.to/sharjeelz/i-built-a-multi-tenant-school-helpdesk-in-laravel-heres-the-full-stack-3nj1</link>
      <guid>https://dev.to/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://dev.to/sharjeelz/the-laravel-queue-multi-tenancy-trap-that-cost-me-3-hours-3c3d</link>
      <guid>https://dev.to/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>
