Laravel Global Scope in Queue Jobs: Why It Should Fail Closed

A Laravel global scope can quietly leak data inside a queue job. I found this bug in a multi-branch app I built. Nothing crashed. No error showed up. The report just ran with the wrong data.

In this post, I explain what went wrong and how to fix it.

The Setup: A Branch Scope

My app has many branches. Each user belongs to one branch. A global scope keeps every query limited to the user's branch.

Here is the first version:

class BranchScope implements Scope { public function apply(Builder $builder, Model $model): void { $branchId = auth()->user()?->branch_id; if ($branchId === null) { return; // no branch, so no filter } $builder->where('branch_id', $branchId); } } 

In a web request, this works well. The user is logged in. The scope knows the branch.

The Bug: Queue Jobs Have No Logged-In User

A queue job runs in the background. No one is logged in. So auth()->user() returns null.

Look at the scope again. With no branch, it returns early. It adds no filter. The query now reads rows from all branches.

Now think about a nightly report job. It was meant to email one branch its own data. Instead, it can include every branch's data. The customer gets numbers that are not theirs.

Why This Is Dangerous: It Fails Open

This kind of bug "fails open". When something is missing, the door opens.

  • The job does not throw an error.
  • The job looks normal in the logs.
  • Nobody notices until a customer does.

A crash is easy to see and fix. A silent leak is not.

The Fix: Fail Closed

Flip the default. If there is no branch, return no rows.

class BranchScope implements Scope { public function apply(Builder $builder, Model $model): void { $branchId = BranchContext::get(); if ($branchId === null) { $builder->whereRaw('1 = 0'); // no branch, no rows return; } $builder->where('branch_id', $branchId); } } 

1 = 0 is always false. So the query returns nothing.

Now a missing branch gives an empty report. You see it in testing. It never reaches a customer.

Pass the Branch Into the Job

The job must carry its own branch. Do not rely on a logged-in user.

class SendBranchReport implements ShouldQueue { use Dispatchable, Queueable; public function __construct(public int $branchId) {} public function handle(): void { BranchContext::set($this->branchId); try { // build and send the report } finally { BranchContext::clear(); } } } 

Dispatch it like this:

SendBranchReport::dispatch($branch->id); 

The finally block matters. Queue workers stay alive and run many jobs. Without clear(), the next job could reuse the old branch.

Admins Need an Explicit Way

Some users, like super admins, must see all branches. Do not use the default for that. Make it a clear choice:

public function scopeWithoutBranchScope(Builder $query): Builder { return $query->withoutGlobalScope(BranchScope::class); } 

Now "see everything" is something you write on purpose. It is never an accident.

Add a Test

This test protects you from future mistakes:

public function test_job_without_branch_returns_no_rows(): void { Invoice::factory()->count(3)->create(); BranchContext::clear(); $this->assertCount(0, Invoice::all()); } 

If someone flips the default back, this test fails.

Key Takeaways

  • A Laravel global scope that depends on auth() breaks inside queue jobs.
  • Missing context should return no rows, not all rows.
  • Pass the branch ID into the job directly.
  • Always clear the context after the job ends.
  • Use a clear method for admin access.
  • Write a test for the "no branch" case.

A silent empty result is a small problem. A silent data leak is a big one. Always choose the small one.

Thanks to the dev.to commenter who pointed this out. Good feedback makes better code.