Branch-Level Data Isolation in Laravel with Global Scopes (and 4 Bugs I Hit)
I built a multi-branch business system in Laravel. Each branch must only see its own invoices, clients, and payments. A global scope solved that in a few lines. Then the bugs started. Here is the setup, and the four pro
I built a multi-branch business system in Laravel. Each branch must only see its own invoices, clients, and payments. A global scope solved that in a few lines. Then the bugs started.
Here is the setup, and the four problems I had to fix.
The trait
trait BelongsToBranch
{
protected static function bootBelongsToBranch(): void
{
static::addGlobalScope('branch', function (Builder $query) {
$user = auth()->user();
if (! $user || $user->isAdmin()) {
return;
}
$query->where(
$query->getModel()->getTable() . '.branch_id',
$user->branch_id
);
});
static::creating(function ($model) {
if (auth()->check() && empty($model->branch_id)) {
$model->branch_id = auth()->user()->branch_id;
}
});
}
}
Add use BelongsToBranch; to any model, and every query is filtered automatically.
Bug 1: "Column 'branch_id' is ambiguous"
My first version used where('branch_id', ...). It broke as soon as a query joined two tables that both had branch_id.
Fix: always qualify the column with the table name, as in the trait above.
Bug 2: Shared records disappear inside relations
Some clients are shared across branches. When a branch user opened an invoice, $invoice->client returned null, because the scope also ran on the related query.
Fix: remove the scope on that specific relation:
public function client()
{
return $this->belongsTo(Client::class)
->withoutGlobalScope('branch');
}
The hard part is consistency. If you remove it in one place but not in an eager load or a whereHas, the same record shows on one page and vanishes on another.
Bug 3: Queued jobs see everything
Jobs and commands run without a logged-in user. My scope returned early when there was no user, so a queued report job read data from all branches.
Fix: never rely on auth() inside jobs. Pass the branch explicitly:
Invoice::withoutGlobalScope('branch')
->where('branch_id', $this->branchId)
->get();
Bug 4: Unique validation ignores the scope
Rule::unique('invoices', 'number') queries the table directly. Global scopes don't apply, so invoice number INV-001 in branch A blocked INV-001 in branch B.
Fix: add the branch condition yourself:
Rule::unique('invoices', 'number')
->where('branch_id', auth()->user()->branch_id)
The same applies to raw DB::table() queries. Global scopes only work through Eloquent.
Takeaway
Global scopes are a clean way to isolate data, but they work silently. Test three cases every time: a branch user, an admin, and a queued job.
I build multi-branch and SaaS systems in Laravel. If you need one, you can find me here: [YOUR ADMIN PANEL / SAAS GIG LINK]
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.