<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://shabayek.pages.dev/feed.xml" rel="self" type="application/atom+xml" /><link href="https://shabayek.pages.dev/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-10-25T16:16:44+00:00</updated><id>https://shabayek.pages.dev/feed.xml</id><title type="html">Esmail Shabayek</title><subtitle>Engineering leader with 8+ years building and scaling SaaS platforms across backend, web, and mobile. Laravel, Node.js, Python, AWS and GCP.</subtitle><author><name>Esmail Shabayek</name></author><entry><title type="html">Multi-tenant SaaS in Laravel: isolation that fails closed</title><link href="https://shabayek.pages.dev/blog/multi-tenant-saas-laravel/" rel="alternate" type="text/html" title="Multi-tenant SaaS in Laravel: isolation that fails closed" /><published>2026-10-22T07:00:00+00:00</published><updated>2026-10-22T07:00:00+00:00</updated><id>https://shabayek.pages.dev/blog/multi-tenant-saas-laravel</id><content type="html" xml:base="https://shabayek.pages.dev/blog/multi-tenant-saas-laravel/"><![CDATA[<p>In a multi-tenant product, the worst bug isn’t downtime. It’s customer A seeing customer B’s invoices. Most tenant-isolation bugs aren’t clever. They happen when a query, job or cache key simply forgets which tenant it belongs to.</p>

<p>This post covers how to choose a tenancy model, how to make tenant scoping the default instead of something each developer has to remember, and the less obvious places where data leaks between tenants.</p>

<h2 id="choosing-a-tenancy-model">Choosing a tenancy model</h2>

<p>There are three common designs, and each one trades isolation against operational cost:</p>

<table>
  <thead>
    <tr>
      <th>Model</th>
      <th>Isolation</th>
      <th>Cost to operate</th>
      <th>Good fit</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Shared database, <code class="language-plaintext highlighter-rouge">tenant_id</code> column</td>
      <td>Logical only</td>
      <td>Lowest: one schema, one migration run</td>
      <td>Many small tenants, self-serve SaaS</td>
    </tr>
    <tr>
      <td>Schema per tenant (PostgreSQL)</td>
      <td>Stronger</td>
      <td>Medium: migrations run per schema</td>
      <td>Hundreds of mid-size tenants</td>
    </tr>
    <tr>
      <td>Database per tenant</td>
      <td>Strongest</td>
      <td>Highest: connections, backups and migrations multiply</td>
      <td>Few large or regulated customers</td>
    </tr>
  </tbody>
</table>

<p>Most products should <strong>start with a shared database and a <code class="language-plaintext highlighter-rouge">tenant_id</code> column</strong>. It’s the simplest to run, reporting across tenants is just a query, and you can still move a large customer to a dedicated database later. The rest of this post assumes that model, because it’s also the one where isolation depends entirely on your code.</p>

<h2 id="know-who-the-current-tenant-is-in-one-place">Know who the current tenant is, in one place</h2>

<p>Resolve the tenant once per request and store it in a small, container-scoped object:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">namespace</span> <span class="nn">App\Tenancy</span><span class="p">;</span>

<span class="kn">use</span> <span class="nc">App\Models\Tenant</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">RuntimeException</span><span class="p">;</span>

<span class="k">final</span> <span class="kd">class</span> <span class="nc">CurrentTenant</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="kt">?Tenant</span> <span class="nv">$tenant</span> <span class="o">=</span> <span class="kc">null</span><span class="p">;</span>

    <span class="k">public</span> <span class="k">function</span> <span class="n">set</span><span class="p">(</span><span class="kt">?Tenant</span> <span class="nv">$tenant</span><span class="p">):</span> <span class="kt">void</span>
    <span class="p">{</span>
        <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">tenant</span> <span class="o">=</span> <span class="nv">$tenant</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="k">public</span> <span class="k">function</span> <span class="n">get</span><span class="p">():</span> <span class="kt">?Tenant</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">tenant</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="k">public</span> <span class="k">function</span> <span class="n">idOrFail</span><span class="p">():</span> <span class="kt">int</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">tenant</span><span class="o">?-&gt;</span><span class="n">id</span>
            <span class="o">??</span> <span class="k">throw</span> <span class="k">new</span> <span class="nc">RuntimeException</span><span class="p">(</span><span class="s1">'No tenant set for this context.'</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// AppServiceProvider::register()</span>
<span class="nv">$this</span><span class="o">-&gt;</span><span class="n">app</span><span class="o">-&gt;</span><span class="nf">scoped</span><span class="p">(</span><span class="nc">CurrentTenant</span><span class="o">::</span><span class="n">class</span><span class="p">);</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">scoped()</code> rather than <code class="language-plaintext highlighter-rouge">singleton()</code> matters: scoped bindings are reset between requests under Octane and between jobs on a queue worker. A singleton would carry one request’s tenant into the next one.</p>

<p>A middleware fills it in, for example from the subdomain:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">function</span> <span class="n">handle</span><span class="p">(</span><span class="kt">Request</span> <span class="nv">$request</span><span class="p">,</span> <span class="kt">Closure</span> <span class="nv">$next</span><span class="p">):</span> <span class="kt">Response</span>
<span class="p">{</span>
    <span class="nv">$slug</span> <span class="o">=</span> <span class="nb">explode</span><span class="p">(</span><span class="s1">'.'</span><span class="p">,</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="nf">getHost</span><span class="p">())[</span><span class="mi">0</span><span class="p">];</span>

    <span class="nv">$tenant</span> <span class="o">=</span> <span class="nc">Tenant</span><span class="o">::</span><span class="nf">where</span><span class="p">(</span><span class="s1">'slug'</span><span class="p">,</span> <span class="nv">$slug</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">firstOrFail</span><span class="p">();</span>
    <span class="nf">app</span><span class="p">(</span><span class="nc">CurrentTenant</span><span class="o">::</span><span class="n">class</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">set</span><span class="p">(</span><span class="nv">$tenant</span><span class="p">);</span>

    <span class="k">return</span> <span class="nv">$next</span><span class="p">(</span><span class="nv">$request</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="make-scoping-automatic-and-fail-closed">Make scoping automatic and fail closed</h2>

<p>A trait with a global scope applies the tenant filter to every Eloquent query on a model, and stamps <code class="language-plaintext highlighter-rouge">tenant_id</code> on create:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">namespace</span> <span class="nn">App\Tenancy</span><span class="p">;</span>

<span class="kn">use</span> <span class="nc">Illuminate\Database\Eloquent\Builder</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">Illuminate\Database\Eloquent\Model</span><span class="p">;</span>

<span class="kd">trait</span> <span class="nc">BelongsToTenant</span>
<span class="p">{</span>
    <span class="k">protected</span> <span class="k">static</span> <span class="k">function</span> <span class="n">bootBelongsToTenant</span><span class="p">():</span> <span class="kt">void</span>
    <span class="p">{</span>
        <span class="k">static</span><span class="o">::</span><span class="nf">addGlobalScope</span><span class="p">(</span><span class="s1">'tenant'</span><span class="p">,</span> <span class="k">function</span> <span class="p">(</span><span class="kt">Builder</span> <span class="nv">$query</span><span class="p">)</span> <span class="p">{</span>
            <span class="nv">$query</span><span class="o">-&gt;</span><span class="nf">where</span><span class="p">(</span>
                <span class="nv">$query</span><span class="o">-&gt;</span><span class="nf">getModel</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">qualifyColumn</span><span class="p">(</span><span class="s1">'tenant_id'</span><span class="p">),</span>
                <span class="nf">app</span><span class="p">(</span><span class="nc">CurrentTenant</span><span class="o">::</span><span class="n">class</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">idOrFail</span><span class="p">(),</span>
            <span class="p">);</span>
        <span class="p">});</span>

        <span class="k">static</span><span class="o">::</span><span class="nf">creating</span><span class="p">(</span><span class="k">function</span> <span class="p">(</span><span class="kt">Model</span> <span class="nv">$model</span><span class="p">)</span> <span class="p">{</span>
            <span class="nv">$model</span><span class="o">-&gt;</span><span class="n">tenant_id</span> <span class="o">??=</span> <span class="nf">app</span><span class="p">(</span><span class="nc">CurrentTenant</span><span class="o">::</span><span class="n">class</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">idOrFail</span><span class="p">();</span>
        <span class="p">});</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The important detail is <code class="language-plaintext highlighter-rouge">idOrFail()</code>. A common version of this trait only adds the <code class="language-plaintext highlighter-rouge">where</code> clause <em>if</em> a tenant is set. That fails <strong>open</strong>: in a console command, a queued job, or a request that skipped the middleware, the scope does nothing and the query returns every tenant’s rows. Failing <strong>closed</strong> turns that silent leak into a loud exception you’ll catch in development.</p>

<p>When you genuinely need data across tenants, such as in an admin panel or a billing report, opt out explicitly so it’s easy to spot in code review:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">Invoice</span><span class="o">::</span><span class="nf">withoutGlobalScope</span><span class="p">(</span><span class="s1">'tenant'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">where</span><span class="p">(</span><span class="s1">'status'</span><span class="p">,</span> <span class="s1">'overdue'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">get</span><span class="p">();</span>
</code></pre></div></div>

<h2 id="where-tenant-data-quietly-leaks">Where tenant data quietly leaks</h2>

<p>Global scopes only protect Eloquent queries. These are the places they don’t reach.</p>

<h3 id="queued-jobs">Queued jobs</h3>

<p>A job runs in a worker process with no HTTP request and no middleware, so the current tenant is empty. Pass the tenant explicitly and set it before doing any work:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">final</span> <span class="kd">class</span> <span class="nc">SendInvoiceReminders</span> <span class="kd">implements</span> <span class="nc">ShouldQueue</span>
<span class="p">{</span>
    <span class="kn">use</span> <span class="nc">Dispatchable</span><span class="p">,</span> <span class="nc">InteractsWithQueue</span><span class="p">,</span> <span class="nc">Queueable</span><span class="p">,</span> <span class="nc">SerializesModels</span><span class="p">;</span>

    <span class="k">public</span> <span class="k">function</span> <span class="n">__construct</span><span class="p">(</span><span class="k">public</span> <span class="kt">int</span> <span class="nv">$tenantId</span><span class="p">)</span> <span class="p">{}</span>

    <span class="k">public</span> <span class="k">function</span> <span class="n">handle</span><span class="p">(</span><span class="kt">CurrentTenant</span> <span class="nv">$current</span><span class="p">):</span> <span class="kt">void</span>
    <span class="p">{</span>
        <span class="nv">$current</span><span class="o">-&gt;</span><span class="nf">set</span><span class="p">(</span><span class="nc">Tenant</span><span class="o">::</span><span class="nf">findOrFail</span><span class="p">(</span><span class="nv">$this</span><span class="o">-&gt;</span><span class="n">tenantId</span><span class="p">));</span>

        <span class="nc">Invoice</span><span class="o">::</span><span class="nf">where</span><span class="p">(</span><span class="s1">'status'</span><span class="p">,</span> <span class="s1">'overdue'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nb">each</span><span class="p">(</span><span class="cm">/* ... */</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Once you have more than a few jobs, move this into a job middleware or a base class so new jobs can’t forget it.</p>

<h3 id="cache-keys">Cache keys</h3>

<p><code class="language-plaintext highlighter-rouge">Cache::remember('dashboard-stats', ...)</code> is shared by every tenant, so the first tenant to warm the cache shows its numbers to everyone. Always include the tenant in the key:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$key</span> <span class="o">=</span> <span class="s1">'tenant:'</span><span class="mf">.</span><span class="nf">app</span><span class="p">(</span><span class="nc">CurrentTenant</span><span class="o">::</span><span class="n">class</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">idOrFail</span><span class="p">()</span><span class="mf">.</span><span class="s1">':dashboard-stats'</span><span class="p">;</span>
</code></pre></div></div>

<p>The same goes for rate-limiter keys, locks, and anything else stored in Redis.</p>

<h3 id="raw-queries-and-the-query-builder">Raw queries and the query builder</h3>

<p><code class="language-plaintext highlighter-rouge">DB::table('invoices')</code> and <code class="language-plaintext highlighter-rouge">DB::select(...)</code> skip Eloquent, so global scopes never run. Treat any raw query against a tenant table as a code-review flag and add the <code class="language-plaintext highlighter-rouge">tenant_id</code> condition by hand.</p>

<h3 id="unique-constraints">Unique constraints</h3>

<p>A unique index on <code class="language-plaintext highlighter-rouge">email</code> stops two <em>different</em> tenants from each having a user <code class="language-plaintext highlighter-rouge">admin@example.com</code>, and the validation error tells one tenant that the other exists. Make tenant-owned uniqueness composite:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$table</span><span class="o">-&gt;</span><span class="nf">unique</span><span class="p">([</span><span class="s1">'tenant_id'</span><span class="p">,</span> <span class="s1">'email'</span><span class="p">]);</span>
</code></pre></div></div>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">Rule</span><span class="o">::</span><span class="nf">unique</span><span class="p">(</span><span class="s1">'users'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">where</span><span class="p">(</span><span class="s1">'tenant_id'</span><span class="p">,</span> <span class="nf">app</span><span class="p">(</span><span class="nc">CurrentTenant</span><span class="o">::</span><span class="n">class</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">idOrFail</span><span class="p">());</span>
</code></pre></div></div>

<h3 id="files">Files</h3>

<p>Prefix storage paths with the tenant (<code class="language-plaintext highlighter-rouge">tenants/{id}/invoices/...</code>) and generate signed or temporary URLs, rather than serving files by a guessable path.</p>

<h2 id="defence-in-depth-postgresql-row-level-security">Defence in depth: PostgreSQL row-level security</h2>

<p>Application scoping can still be bypassed by a raw query or a forgotten <code class="language-plaintext highlighter-rouge">withoutGlobalScope</code>. On PostgreSQL you can add a second, database-level guard with row-level security:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">ALTER</span> <span class="k">TABLE</span> <span class="n">invoices</span> <span class="n">ENABLE</span> <span class="k">ROW</span> <span class="k">LEVEL</span> <span class="k">SECURITY</span><span class="p">;</span>
<span class="k">ALTER</span> <span class="k">TABLE</span> <span class="n">invoices</span> <span class="k">FORCE</span> <span class="k">ROW</span> <span class="k">LEVEL</span> <span class="k">SECURITY</span><span class="p">;</span>

<span class="k">CREATE</span> <span class="n">POLICY</span> <span class="n">tenant_isolation</span> <span class="k">ON</span> <span class="n">invoices</span>
    <span class="k">USING</span> <span class="p">(</span><span class="n">tenant_id</span> <span class="o">=</span> <span class="n">current_setting</span><span class="p">(</span><span class="s1">'app.tenant_id'</span><span class="p">)::</span><span class="nb">bigint</span><span class="p">);</span>
</code></pre></div></div>

<p>Set the variable on the connection whenever the tenant is resolved:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">DB</span><span class="o">::</span><span class="nf">select</span><span class="p">(</span><span class="s2">"select set_config('app.tenant_id', ?, false)"</span><span class="p">,</span> <span class="p">[(</span><span class="n">string</span><span class="p">)</span> <span class="nv">$tenant</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">]);</span>
</code></pre></div></div>

<p>Two gotchas. Table owners bypass RLS unless you add <code class="language-plaintext highlighter-rouge">FORCE ROW LEVEL SECURITY</code>, as above. And with pooled or persistent connections, the setting must be reset for each request or job, or one tenant’s value carries over into the next. Admin and migration tasks should use a separate database role with <code class="language-plaintext highlighter-rouge">BYPASSRLS</code>.</p>

<h2 id="test-the-boundary-not-just-the-feature">Test the boundary, not just the feature</h2>

<p>Tenant isolation deserves its own tests, because no feature test will notice a leak:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">function</span> <span class="n">test_tenants_cannot_see_each_others_invoices</span><span class="p">():</span> <span class="kt">void</span>
<span class="p">{</span>
    <span class="p">[</span><span class="nv">$a</span><span class="p">,</span> <span class="nv">$b</span><span class="p">]</span> <span class="o">=</span> <span class="nc">Tenant</span><span class="o">::</span><span class="nf">factory</span><span class="p">()</span><span class="o">-&gt;</span><span class="nb">count</span><span class="p">(</span><span class="mi">2</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">create</span><span class="p">();</span>

    <span class="nf">app</span><span class="p">(</span><span class="nc">CurrentTenant</span><span class="o">::</span><span class="n">class</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">set</span><span class="p">(</span><span class="nv">$a</span><span class="p">);</span>
    <span class="nv">$invoice</span> <span class="o">=</span> <span class="nc">Invoice</span><span class="o">::</span><span class="nf">factory</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">create</span><span class="p">();</span>

    <span class="nf">app</span><span class="p">(</span><span class="nc">CurrentTenant</span><span class="o">::</span><span class="n">class</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">set</span><span class="p">(</span><span class="nv">$b</span><span class="p">);</span>
    <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">assertNull</span><span class="p">(</span><span class="nc">Invoice</span><span class="o">::</span><span class="nf">find</span><span class="p">(</span><span class="nv">$invoice</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">));</span>
<span class="p">}</span>

<span class="k">public</span> <span class="k">function</span> <span class="n">test_queries_without_a_tenant_fail</span><span class="p">():</span> <span class="kt">void</span>
<span class="p">{</span>
    <span class="nf">app</span><span class="p">(</span><span class="nc">CurrentTenant</span><span class="o">::</span><span class="n">class</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">set</span><span class="p">(</span><span class="kc">null</span><span class="p">);</span>

    <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">expectException</span><span class="p">(</span><span class="nc">RuntimeException</span><span class="o">::</span><span class="n">class</span><span class="p">);</span>
    <span class="nc">Invoice</span><span class="o">::</span><span class="nb">count</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The second test is the one that guards everything else: it proves the system fails closed.</p>

<h2 id="summary">Summary</h2>

<ul>
  <li>Start with a shared database and <code class="language-plaintext highlighter-rouge">tenant_id</code> unless isolation or compliance requirements say otherwise.</li>
  <li>Resolve the tenant once, in a <code class="language-plaintext highlighter-rouge">scoped</code> binding, and make every query depend on it.</li>
  <li>Throw when no tenant is set instead of quietly returning everything.</li>
  <li>Treat jobs, cache keys, raw queries, unique indexes and files as tenant-aware by default.</li>
  <li>On PostgreSQL, add row-level security as a second line of defence.</li>
</ul>]]></content><author><name>Esmail Shabayek</name></author><category term="Laravel" /><category term="SaaS" /><category term="Architecture" /><category term="PostgreSQL" /><summary type="html"><![CDATA[Picking a tenancy model, scoping every query to the current tenant, and the places tenant data quietly leaks — queues, cache, files and raw queries.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://shabayek.pages.dev/images/blog/multi-tenant-saas-laravel.jpg" /><media:content medium="image" url="https://shabayek.pages.dev/images/blog/multi-tenant-saas-laravel.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>