Dev.to WebDev 🛠 Dev 👁 0 📖 2 min read

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]

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.