Why Global Scopes Fail When You Chain orWhere Without a Closure

Multi-tenant Laravel applications depend on global scopes to enforce data isolation. A scope on the User model means every query automatically filters by tenant_id. It’s a foundation of security. But the moment a developer chains .orWhere() at the root level of a query without wrapping it in a closure, that foundation cracks. The scope no longer applies to half the OR condition. Data from other tenants leaks through.
This is not a theoretical problem. Multi-tenant data leakage represents a critical vulnerability class in SaaS systems. The issue often surfaces through broken row-level security (RLS) implementations, where architectural flaws cause isolation mechanisms to fail silently. One common manifestation occurs when query builders fail to properly group OR conditions within scoped contexts, allowing unintended rows to bypass tenant filters.
The problem lives in production code right now. It hides in queries that look correct at first glance. A global scope filters for tenant_id = 1. Then a developer appends .where(‘status’, ‘active’)->orWhere(‘role’, ‘admin’). The SQL AND operator has higher precedence than OR. Suddenly the scope breaks. Suddenly other tenants’ rows flow through. The breach is silent. The user sees data they should never access. The code review might miss it entirely.
Understanding Operator Precedence and Its Cost
Every database system either MySQL, PostgreSQL, SQL Server, follows the same precedence rule: AND evaluates before OR. This is not a bug. It is foundational. AND binds tighter than OR, just as multiplication binds tighter than addition in mathematics.
Take a concrete example:
$users = User::where('tenant_id', 1)
->where('status', 'active')
->orWhere('role', 'admin')
->get();The global scope on the User model adds a WHERE constraint automatically. Laravel chains the conditions with AND. The raw SQL becomes:
WHERE tenant_id = 1 AND status = 'active' OR role = 'admin'
Now parse that like the database does. AND binds tighter, so this is actually:
WHERE (tenant_id = 1 AND status = 'active') OR (role = 'admin')
The query now asks for every record where either:
- The tenant is 1 AND the status is active
- OR the role is admin (from any tenant)
If another tenant has an admin user, that user appears in your result set. The global scope is completely bypassed for the second half of the OR condition. The tenant_id filter no longer applies to rows matching role = 'admin'.
This is not a bug in Laravel. This is SQL. The mistake is writing the query without understanding precedence.
Why Global Scopes Break Under OR Conditions
Laravel’s global scope system is designed to enforce invariants automatically. Define a scope on a model, and every single query on that model includes the scope constraint. The developer does not have to think about it:
class User extends Model
{
protected static function booted()
{
static::addGlobalScope('tenant', function (Builder $query) {
$query->where('tenant_id', auth()->user()->tenant_id);
});
}
}
The scope adds the WHERE clause at the root level:
WHERE tenant_id = 1 AND ...
If you then chain .orWhere() at the root level, you are adding a condition that sits outside the implicit grouping of the scope. The scope checks for tenant_id. Your orWhere bypasses it. The database sees a top-level OR that applies to the entire result set, not to a subset. The constraint collapses.
The closure pattern fixes this because it creates explicit parentheses. When you write:
$users = User::where(function (Builder $query) {
$query->where('status', 'active')
->orWhere('role', 'admin');
})->get();The SQL becomes:
WHERE tenant_id = 1 AND (status = 'active' OR role = 'admin')
Now the OR is trapped inside parentheses. The tenant_id filter applies to all rows. The scope holds.
When a global scope is in effect and you chain orWhere without a closure, the scope’s WHERE clause sits at the top level, and your orWhere sits at the same level. They sit side by side. Operator precedence collapses the tenant filter. This is why relationship queries are especially vulnerable. When you query through a relationship:
$organization->users()->where('role', 'x')->orWhere('age', 10)->get();The relationship adds an implicit constraint to the query builder. The global scope adds its constraint. Then your orWhere lives at the query root, outside any grouping. The result: you get users from the organization with role x, plus any user aged 10 from any organization. The organization_id constraint from the relationship becomes useless the moment OR enters the picture at the root level. Any user aged 10, regardless of which organization they belong to, leaks into the result set.
What Leaking Data Looks Like in Practice
This is not academic. The damage in production systems is concrete and measurable.
Consider a scenario where a contractor management platform serves 50 companies. Each company has contractors, invoices and rates. The invoicing query might look like this:
$invoices = Invoice::where('company_id', auth()->user()->company_id)
->where('status', 'pending')
->orWhere('overdue', true)
->get();A user logs into Company A and navigates to their pending invoices. The query also returns overdue invoices from Company B, Company C and Company D. Those other companies are not filtered out. The orWhere removed them from the constraint set. The user sees dollar amounts, client names, payment terms and project descriptions from competitors. They did not have to be malicious. They just clicked “filter by overdue” and the isolation broke.
Similarly, consider a scenario in a healthcare system. A nurse needs to query for urgent patient notes. The query:
$notes = PatientNote::where('clinic_id', auth()->user()->clinic_id)
->where('priority', 'urgent')
->orWhere('flagged', true)
->get();Returned flagged notes from other clinics. Flagged patient records from clinics they do not work in. Flagged treatment notes from other organizations. HIPAA violations in one misstep. The damage to trust and reputation is permanent. A compliance audit would find the breach and the fines follow.
In a property management platform, tenants could see rent modification history for other properties by querying their own property for modifications where status was pending OR where the property_id was flagged. The orWhere at the root level meant that flagged modifications from every property appeared in the result. A tenant could see rent changes across the entire portfolio.
This pattern appears in query logs across production Laravel applications every single day. Most companies never discover it because their data volumes are small or their users are trusted. Some do discover it and face regulatory action, data breach notifications and class action lawsuits.
The Fix: Wrap Combined Conditions in a Closure
The solution is mechanical and non-negotiable. When you combine WHERE and OR in any context with a pre-existing constraint, wrap the combined conditions in a closure.
Instead of this:
$users = User::where('status', 'active')->orWhere('role', 'admin')->get();Write this:
$users = User::where(function (Builder $query) {
$query->where('status', 'active')
->orWhere('role', 'admin');
})->get();Or in modern Laravel versions with arrow functions:
$users = User::where(fn($q) => $q->where('status', 'active')->orWhere('role', 'admin'))->get();The closure forces Laravel to wrap the conditions in parentheses at the SQL level. The generated query becomes:
WHERE (status = 'active' OR role = 'admin')
If you have a global scope that adds tenant_id = 1, the scope and the closure are combined with AND:
WHERE tenant_id = 1 AND (status = 'active' OR role = 'admin')
Now the tenant filter applies to all rows. The OR is logically grouped. The scope holds.
For relationship queries, the same principle applies. Instead of:
$organization->users()->where('role', 'x')->orWhere('age', 10)->get();Write:
$organization->users()->where(fn($q) =>
$q->where('role', 'x')->orWhere('age', 10)
)->get();
This forces the relationship constraint to apply to both conditions in the OR. No user outside the organization appears in the result set, regardless of age.
In practice, the mental model is simple: every time you use orWhere in a query that has a pre-existing constraint (a scope, a relationship, or an earlier where clause), ask yourself whether the OR should apply to that constraint or bypass it. If it should apply, use a closure. If you are unsure, use a closure anyway. You cannot lose by being explicit.
Auditing Your Codebase for This Vulnerability
If you maintain a Laravel application with more than 5,000 lines of code, you probably have at least one orWhere() sitting at the wrong level. Finding them is systematic and repeatable.
First, search for all instances of orWhere in your codebase:
grep -r "->orWhere(" app/ --include="*.php"For each result, read the context. Ask three questions: Is there a where() clause before this orWhere()? Is this query on a model with a global scope? Is this a relationship query? If the answer to any of these is yes and the orWhere is not wrapped in a closure, you have a potential vulnerability.
A more detailed grep pattern can help narrow down risky cases:
grep -rn "->where(" app/ --include="*.php" | grep -A 2 "->orWhere("This will show you cases where where() and orWhere() appear in close proximity, which are the high-risk patterns.
You can also search specifically for orWhere calls that are chained at the root level of a query:
grep -r "->orWhere(" app/ --include="*.php" | grep -v "^\s*->" | head -20A more reliable approach is to use Larastan, a static analysis tool for Laravel. Larastan can detect many query builder mistakes before they reach production. You can configure it to flag root-level orWhere() calls:
composer require --dev phpstan/phpstan
Add a phpstan.neon.dist file:
parameters:
paths:
- app
level: 5
extensions:
- Larastan\Larastan\Extension
Run it:
./vendor/bin/phpstan analyse
Larastan will highlight suspicious query patterns. It will not catch everything, but it catches the obvious cases.
The most reliable approach is a code review process that specifically checks for this pattern. When reviewing any query that uses both where() and orWhere(), the reviewer should verify that either the orWhere is wrapped in a closure or the developer can articulate why the OR should bypass the earlier constraint. This is not a style preference. It is a security gate. If the developer cannot explain their reasoning, the code does not merge.
You might also add a regression test. For any model with a global scope:
public function test_or_where_does_not_leak_across_scopes()
{
$tenant1 = Tenant::create(['name' => 'Tenant 1']);
$tenant2 = Tenant::create(['name' => 'Tenant 2']);
auth()->login($tenant1->admin);
$user1 = User::create([
'tenant_id' => $tenant1->id,
'name' => 'Alice',
'role' => 'user'
]);
$user2 = User::create([
'tenant_id' => $tenant2->id,
'name' => 'Bob',
'role' => 'admin'
]);
// This query should only return user1, never user2
$results = User::where('role', 'user')
->orWhere('role', 'admin')
->get();
$this->assertCount(1, $results);
$this->assertEquals($user1->id, $results->first()->id);
$this->assertFalse($results->pluck('id')->contains($user2->id));
}
This test verifies that a user from Tenant 2 never leaks into a query executed in the context of Tenant 1, even when the query uses orWhere and the second tenant has a user with the matching criteria.
Testing Scope Isolation in CI Pipelines
Unit tests catch the obvious mistakes. Integration tests catch the subtle ones. Build a test suite specifically for scope isolation. For every model with a global scope, write tests that verify:
- A query with only where() returns only scoped results
- A query with where() and orWhere() wrapped in a closure returns only scoped results
- A relationship query with orWhere() wrapped in a closure returns only related results from the current scope
- Scope bypass with withoutGlobalScope() returns cross-scoped results (verifying that intentional bypass works as intended)
- Queries with complex OR chains do not bypass scopes when properly grouped
In your CI pipeline, run these tests before every deploy. If a deployment system uses GitLab CI:
test:scope_isolation:
stage: test
script:
- php artisan test tests/Feature/ScopeIsolationTest.php --stop-on-failure
If any test fails, the deployment is blocked. This is not optional. Scope isolation is a security boundary, not a performance optimization or a nice-to-have. It is a hard requirement in multi-tenant systems.
Additionally, instrument your logs to detect when queries cross scope boundaries. In your query logger or observability platform, flag queries that return results from multiple tenants when only one tenant was expected:
// In a middleware or service provider
DB::listen(function (QueryExecuted $query) {
$expectedTenant = auth()->user()->tenant_id ?? null;
if ($expectedTenant && str_contains($query->sql, 'SELECT')) {
$bindings = $query->bindings;
// Log this query for later analysis
Log::debug('Query executed', [
'sql' => $query->sql,
'tenant_id' => $expectedTenant,
'duration' => $query->time
]);
}
});
This gives you a record of what queries are running in production. If you see a query that is not filtered by tenant_id when one should be present, that is a red flag. Set up alerts in your observability platform to catch these patterns automatically.
Some teams take this further and instrument their query logger to count the distinct tenant IDs in result sets. If a single query returns rows from multiple tenants, that is logged as a security event. Over time, this pattern detection becomes automatic and catches new vulnerabilities before they cause breaches.
Scope Isolation as a Correctness Requirement
Developers often think about scope bypass as a security problem alone. It is. But it is also a correctness problem.
If your queries do not respect scopes, your data is unreliable. A report that sums invoices across tenants is a report that is wrong. A dashboard that displays user counts from other organizations is a dashboard showing incorrect data. The data is corrupted by scope failure. The business logic depends on data isolation, not just for privacy but for correctness.
This is why the best multi-tenant Laravel applications treat global scopes as inviolable constraints. They are not suggestions. They are laws. Every query obeys them. When the structure of a query would require a scope to be bypassed, the developer is forced to be explicit about it. They call withoutGlobalScope() and document why. The absence of an explicit bypass is a guarantee that the scope was respected.
// Explicitly bypassing a scope requires documentation
$allUsers = User::withoutGlobalScope('tenant')
->where('status', 'admin')
->get(); // Danger: returns users from all tenants
This discipline prevents data corruption, not just data leakage. Over months or years, small scope failures compound. Reports are wrong. Metrics are inflated. Aggregations span tenants. The integrity of the entire system degrades.
Deployment and Ongoing Monitoring
The pattern is simple to fix once you see it. Wrap your combined where and orWhere conditions in a closure. Verify that this applies to relationship queries too. Write tests. Run them in CI. Move on.
The pattern is hard to see because it looks correct at first glance. The query works. It returns results. It is only under detailed inspection (or during an audit, or after a data breach) that you realize it is returning results from the wrong scope.
This is why you need systematic checks: code review, static analysis and automated tests. Not all three working in parallel. Any one of them failing to catch this pattern is one missed vulnerability in production.
Treat scope isolation as a testable property of your application, not as a feature to be added. Test it. Assert it. Verify it before every deployment. The contractor management platform that leaked invoices, the healthcare system that violated HIPAA, the property management platform that exposed lease terms: all of them would have caught this with one test case and five minutes of CI time.
Write the test. Run it. Block deployments if it fails. Deploy the fix. Log the vulnerability so you can track it. Move on to the next one.
Sources:
- Multi-Tenant Leakage: When Row-Level Security Fails in SaaS
- IDOR vs BOLA: Insecure Direct Object Reference Explained 2025
- Avoid data leakage when using orWhere on a relationship
- Laravel for SaaS: How to Keep Multi-Tenant Data Safe
- Use Laravel Observers and Global Scopes to Create User Multi-Tenancy
- Learn to master Query Scopes in Laravel
- Building Multi-Tenant SaaS with Row-Level Security in Laravel
- SQL Operator Precedence
- Multi-Tenant Architecture: A Complete Guide for 2026
- Testing in Tenancy For Laravel