In the multi-tenant SaaS I built on top of an open-source messaging gateway, every meaningful row belongs to an org, and the cardinal sin is showing one org another org's data. You can try to prevent that by remembering to add where('org_id', ...) to every query, which works right up until the one place someone forgets. A better answer is to make forgetting impossible in the normal case: a global scope that filters every query by the current org automatically.
class OrgScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
if (! app()->bound('current_org')) {
return; // no request context (queue worker, artisan): deliberately a no-op (see below)
}
$builder->where("{$model->getTable()}.org_id", app('current_org')->id);
}
}
A piece of middleware sets the current org from the authenticated request, into the container:
// EnforceOrgContext middleware: runs on every authenticated request.
app()->instance('current_org', $request->user()->currentOrg);
After that, request code is safe by default. Every query on an org-owned model is scoped without anyone writing a where, and a developer who forgets the org filter doesn't create a leak, because there's nothing to forget. That's the property you want: the safe thing happens when you do nothing special.
Then the request goes away
Here's the edge, and it's the whole reason this is worth writing about. A queue job has no request behind it. Neither does an artisan command, or the scheduler. There's no authenticated user, so the middleware never ran, so current_org was never bound. Look back at the scope: when current_org isn't bound, it's a deliberate no-op. The filter switches off.
That is exactly correct, and it's a trap if you don't see it coming. It's correct because a job that processes every org's nightly rollup must see all orgs; binding it to one would be the bug. It's a trap because a job that's supposed to act on one org will, if you do nothing, silently operate across all of them. The scope that protected your request code gives you no protection here, because there's no request to read the tenant from.
You handle the boundary explicitly, on purpose
So work that runs without a request states its tenancy out loud. A job that belongs to one org carries that org and sets the context itself:
public function handle(): void
{
app()->instance('current_org', $this->org); // re-establish the context the request would have set
// ...now org-scoped queries behave exactly as they do in a web request
}
And a job that genuinely spans every tenant opts out of the scope, visibly, so the intent is unmistakable to the next reader:
Invoice::withoutGlobalScope(OrgScope::class)
->where('due_today', true)
->each(fn ($invoice) => $invoice->process()); // cross-tenant ON PURPOSE
The withoutGlobalScope isn't a workaround. It's the design working as intended: a cross-tenant operation is rare and dangerous, so it should be impossible to do by accident and trivial to spot in review. When you see it in a diff, you know exactly what to scrutinize.
What the pattern actually trades
A global scope moves the safety from discipline to default, which is a huge win on the request path, where most of your code lives and most of your leaks would otherwise hide. The cost is a single conceptual sharp edge: the scope is only as present as the request context that feeds it, and the places without a request, jobs, commands, the scheduler, are exactly where you have to be deliberate. Name that edge, set the context where a job needs one org, opt out loudly where it needs all of them, and the multi-tenancy stays both safe-by-default and honest about the one place the default doesn't apply.