<?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: Abdullah Ubayed Tanvir</title>
    <description>The latest articles on DEV Community by Abdullah Ubayed Tanvir (@ubayedtanvir).</description>
    <link>https://dev.to/ubayedtanvir</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4200556%2F83c81abf-8b1c-4b61-ba5d-d2aa0a65e729.jpg</url>
      <title>DEV Community: Abdullah Ubayed Tanvir</title>
      <link>https://dev.to/ubayedtanvir</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ubayedtanvir"/>
    <language>en</language>
    <item>
      <title>Why my Laravel tenancy package throws when there's no tenant</title>
      <dc:creator>Abdullah Ubayed Tanvir</dc:creator>
      <pubDate>Wed, 04 Nov 2026 15:04:03 +0000</pubDate>
      <link>https://dev.to/ubayedtanvir/why-my-laravel-tenancy-package-throws-when-theres-no-tenant-12o</link>
      <guid>https://dev.to/ubayedtanvir/why-my-laravel-tenancy-package-throws-when-theres-no-tenant-12o</guid>
      <description>&lt;p&gt;Most multi-tenancy bugs I've worried about in Laravel apps aren't dramatic. Nobody hacks anything. A query runs without a tenant filter, returns a few extra rows, and those rows end up in a CSV export, a dashboard widget, or an email that goes to the wrong customer.&lt;/p&gt;

&lt;p&gt;When I wrote &lt;a href="https://github.com/ubayedtanvir/laravel-tenancy" rel="noopener noreferrer"&gt;laravel-tenancy&lt;/a&gt;, a single-database tenancy package for Laravel, I made one decision early and built everything else around it: if the package isn't sure which tenant a query belongs to, it refuses to run the query. This post explains that choice, what it costs, and how it plays out in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The usual setup, and where it leaks
&lt;/h2&gt;

&lt;p&gt;Single-database tenancy (sometimes called shared-schema or row-level tenancy) is simple on paper. Every tenant-owned table has a &lt;code&gt;team_id&lt;/code&gt; column. You add a global scope that appends &lt;code&gt;where team_id = ?&lt;/code&gt; to every query. Done.&lt;/p&gt;

&lt;p&gt;The weak spot is what happens when there's no current tenant. With a typical hand-rolled scope, the code looks something like this:&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;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;$query&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;session&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="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$query&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;'team_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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;tenant_id&lt;/code&gt; is missing, the &lt;code&gt;if&lt;/code&gt; is skipped and the query runs unfiltered. That's "fail open". It's convenient during development because things keep working. It's dangerous later because things keep working.&lt;/p&gt;

&lt;p&gt;The places where the tenant goes missing are predictable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Queued jobs, which run on a worker with no request and no session&lt;/li&gt;
&lt;li&gt;Scheduled commands and Artisan commands&lt;/li&gt;
&lt;li&gt;Tinker sessions and one-off scripts&lt;/li&gt;
&lt;li&gt;Tests that forget to set up a tenant&lt;/li&gt;
&lt;li&gt;A new route someone added outside the tenant middleware group&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these throw errors with a fail-open scope. They just return data from every tenant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fail closed: no tenant, no query
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://github.com/ubayedtanvir/laravel-tenancy" rel="noopener noreferrer"&gt;laravel-tenancy&lt;/a&gt;, a model opts in with the &lt;code&gt;BelongsToTenant&lt;/code&gt; trait:&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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;UbayedTanvir\LaravelTenancy\Concerns\BelongsToTenant&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;Invoice&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you query &lt;code&gt;Invoice&lt;/code&gt; without a tenant context, you get an exception, not an empty result and not every row. The same trait also:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;stamps the current tenant on create, so you never set &lt;code&gt;team_id&lt;/code&gt; by hand&lt;/li&gt;
&lt;li&gt;guards the tenant column from mass assignment&lt;/li&gt;
&lt;li&gt;rejects writes when a model's tenant doesn't match the current one&lt;/li&gt;
&lt;li&gt;makes the tenant column immutable after creation, so a record can't be moved to another tenant by accident&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The idea is that the safe path should be the default path, and the unsafe path should be loud. An exception in a queued job shows up in your error tracker the first time it happens. A silent leak can sit there for a long time before anyone notices.&lt;/p&gt;

&lt;h2&gt;
  
  
  But sometimes you do need all tenants
&lt;/h2&gt;

&lt;p&gt;Admin dashboards, billing reports and data migrations legitimately need to read across tenants. Fail closed doesn't mean you can't do that. It means you have to say so in code:&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;Invoice&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;acrossTenants&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;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'overdue'&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;count&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nc"&gt;Invoice&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;forTenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$team&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;latest&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="nc"&gt;Tenancy&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;crossTenant&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="c1"&gt;// anything in here runs without tenant scoping&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every one of these dispatches a &lt;code&gt;CrossTenantAccessGranted&lt;/code&gt; event, so you can log or audit it. And because each escape hatch has a distinct name, you can find all of them with one search:&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="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"crossTenant&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;acrossTenants&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;withoutTenancy"&lt;/span&gt; app/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I like this property more than I expected to. In a code review, a new &lt;code&gt;acrossTenants()&lt;/code&gt; call stands out. In a fail-open setup, the dangerous code is the code that &lt;em&gt;doesn't&lt;/em&gt; do something, which is much harder to spot in a diff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Queues: carry the tenant with the job
&lt;/h2&gt;

&lt;p&gt;Queued work is the most common place a tenant gets lost, so the package handles it directly. When you dispatch a job, the current tenant ID is written into the queue payload (through Laravel's &lt;code&gt;Queue::createPayloadUsing&lt;/code&gt; hook). When the worker picks the job up, it restores that tenant before your code runs.&lt;/p&gt;

&lt;p&gt;This covers jobs, mailables, notifications, queued listeners, closures, chains and batches. You don't pass &lt;code&gt;$teamId&lt;/code&gt; into every job constructor and hope nobody forgets.&lt;/p&gt;

&lt;p&gt;If a job really is global, such as a nightly cleanup across all tenants, it implements the &lt;code&gt;NotTenantAware&lt;/code&gt; interface and runs without a tenant. Again, the exception is explicit.&lt;/p&gt;

&lt;p&gt;For Artisan commands, &lt;code&gt;tenancy:run&lt;/code&gt; runs any command inside a tenant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan tenancy:run &lt;span class="s2"&gt;"cache:clear"&lt;/span&gt; &lt;span class="nt"&gt;--tenant&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;acme
php artisan tenancy:run &lt;span class="s2"&gt;"cache:clear"&lt;/span&gt; &lt;span class="nt"&gt;--all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Checking the schema, not just the code
&lt;/h2&gt;

&lt;p&gt;Runtime checks only catch problems in code paths that actually run. Some tenancy mistakes live in the database schema instead, so the package ships a &lt;code&gt;tenancy:audit&lt;/code&gt; command. It looks for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;models on tenant tables that are missing &lt;code&gt;BelongsToTenant&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;unique indexes that should include the tenant column but don't (an invoice &lt;code&gt;number&lt;/code&gt; that's unique globally instead of per tenant is a classic one)&lt;/li&gt;
&lt;li&gt;tenant column type mismatches with the tenant model's key&lt;/li&gt;
&lt;li&gt;missing foreign keys&lt;/li&gt;
&lt;li&gt;whether strict mode is on&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each finding comes with a suggested fix. In CI you can make warnings fail the build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan tenancy:audit &lt;span class="nt"&gt;--fail-on&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;warn
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Testing isolation
&lt;/h2&gt;

&lt;p&gt;The last piece is making isolation something you test, not something you assume. The &lt;code&gt;InteractsWithTenancy&lt;/code&gt; test trait has one assertion that covers the main failure modes for a model:&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertTenantIsolated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Invoice&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="nv"&gt;$teamA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$teamB&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It checks that tenant A can't read tenant B's rows, that cross-tenant writes are rejected, that ownership can't be changed, and that querying without a tenant throws. One line per model is cheap enough that it actually gets written.&lt;/p&gt;

&lt;h2&gt;
  
  
  What fail closed costs you
&lt;/h2&gt;

&lt;p&gt;It isn't free, and I'd rather be upfront about that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;More exceptions early on.&lt;/strong&gt; When you first add the trait to an existing app, code paths that were silently relying on "no tenant means everything" will start throwing. That's the point, but it's still work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seeders and factories need a tenant.&lt;/strong&gt; You have to wrap them in &lt;code&gt;Tenancy::runFor($team, ...)&lt;/code&gt; or similar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Global features need a decision.&lt;/strong&gt; Every admin or reporting feature has to choose its escape hatch explicitly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For me, that trade is worth it. An exception during development costs minutes. A leak in production costs a conversation with a customer that nobody wants to have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it fits
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/ubayedtanvir/laravel-tenancy" rel="noopener noreferrer"&gt;laravel-tenancy&lt;/a&gt; is for single-database, row-level tenancy. If you want a database per tenant, &lt;a href="https://github.com/spatie/laravel-multitenancy" rel="noopener noreferrer"&gt;spatie/laravel-multitenancy&lt;/a&gt; and &lt;a href="https://github.com/archtechx/tenancy" rel="noopener noreferrer"&gt;stancl/tenancy&lt;/a&gt; are both mature and well documented.&lt;/p&gt;

&lt;p&gt;It requires PHP 8.4+ and Laravel 13, and it's MIT licensed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require ubayedtanvir/laravel-tenancy
php artisan tenancy:install
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The code is on GitHub at &lt;a href="https://github.com/ubayedtanvir/laravel-tenancy" rel="noopener noreferrer"&gt;ubayedtanvir/laravel-tenancy&lt;/a&gt;. If you run multi-tenant Laravel apps, I'd like to hear how you handle the no-tenant case today, and whether there's a failure mode I haven't covered. Issues and discussions are open.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
