Why my Laravel tenancy package throws when there's no tenant
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
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.
When I wrote laravel-tenancy, 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.
The usual setup, and where it leaks
Single-database tenancy (sometimes called shared-schema or row-level tenancy) is simple on paper. Every tenant-owned table has a team_id column. You add a global scope that appends where team_id = ? to every query. Done.
The weak spot is what happens when there's no current tenant. With a typical hand-rolled scope, the code looks something like this:
static::addGlobalScope('tenant', function (Builder $query) {
if ($tenantId = session('tenant_id')) {
$query->where('team_id', $tenantId);
}
});
If tenant_id is missing, the if 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.
The places where the tenant goes missing are predictable:
- Queued jobs, which run on a worker with no request and no session
- Scheduled commands and Artisan commands
- Tinker sessions and one-off scripts
- Tests that forget to set up a tenant
- A new route someone added outside the tenant middleware group
None of these throw errors with a fail-open scope. They just return data from every tenant.
Fail closed: no tenant, no query
In laravel-tenancy, a model opts in with the BelongsToTenant trait:
use UbayedTanvir\LaravelTenancy\Concerns\BelongsToTenant;
class Invoice extends Model
{
use BelongsToTenant;
}
If you query Invoice without a tenant context, you get an exception, not an empty result and not every row. The same trait also:
- stamps the current tenant on create, so you never set
team_idby hand - guards the tenant column from mass assignment
- rejects writes when a model's tenant doesn't match the current one
- makes the tenant column immutable after creation, so a record can't be moved to another tenant by accident
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.
But sometimes you do need all tenants
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:
Invoice::acrossTenants()->where('status', 'overdue')->count();
Invoice::forTenant($team)->latest()->get();
Tenancy::crossTenant(function () {
// anything in here runs without tenant scoping
});
Every one of these dispatches a CrossTenantAccessGranted 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:
grep -rn "crossTenant\|acrossTenants\|withoutTenancy" app/
I like this property more than I expected to. In a code review, a new acrossTenants() call stands out. In a fail-open setup, the dangerous code is the code that doesn't do something, which is much harder to spot in a diff.
Queues: carry the tenant with the job
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 Queue::createPayloadUsing hook). When the worker picks the job up, it restores that tenant before your code runs.
This covers jobs, mailables, notifications, queued listeners, closures, chains and batches. You don't pass $teamId into every job constructor and hope nobody forgets.
If a job really is global, such as a nightly cleanup across all tenants, it implements the NotTenantAware interface and runs without a tenant. Again, the exception is explicit.
For Artisan commands, tenancy:run runs any command inside a tenant:
php artisan tenancy:run "cache:clear" --tenant=acme
php artisan tenancy:run "cache:clear" --all
Checking the schema, not just the code
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 tenancy:audit command. It looks for:
- models on tenant tables that are missing
BelongsToTenant - unique indexes that should include the tenant column but don't (an invoice
numberthat's unique globally instead of per tenant is a classic one) - tenant column type mismatches with the tenant model's key
- missing foreign keys
- whether strict mode is on
Each finding comes with a suggested fix. In CI you can make warnings fail the build:
php artisan tenancy:audit --fail-on=warn
Testing isolation
The last piece is making isolation something you test, not something you assume. The InteractsWithTenancy test trait has one assertion that covers the main failure modes for a model:
$this->assertTenantIsolated(Invoice::class, $teamA, $teamB);
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.
What fail closed costs you
It isn't free, and I'd rather be upfront about that:
- More exceptions early on. 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.
-
Seeders and factories need a tenant. You have to wrap them in
Tenancy::runFor($team, ...)or similar. - Global features need a decision. Every admin or reporting feature has to choose its escape hatch explicitly.
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.
Where it fits
laravel-tenancy is for single-database, row-level tenancy. If you want a database per tenant, spatie/laravel-multitenancy and stancl/tenancy are both mature and well documented.
It requires PHP 8.4+ and Laravel 13, and it's MIT licensed:
composer require ubayedtanvir/laravel-tenancy
php artisan tenancy:install
The code is on GitHub at ubayedtanvir/laravel-tenancy. 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.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.