← All writing

Group OR conditions in Laravel so tenant filters still apply

Laravel 13Sources checked 2026-09-12

Tenant condition AND grouped alternatives. Test a cross-tenant match.

A customer searches invoices by number or reference. The query begins with a tenant filter, then adds an ungrouped OR. That OR can change the meaning of the entire condition and expose records outside the intended account.

Keep the alternatives inside one group

use Illuminate\Support\Facades\DB;

$invoices = DB::table('invoices')
    ->where('tenant_id', $authorizedTenantId)
    ->where(function ($query) use ($search) {
        $query->where('number', $search)
            ->orWhere('reference', $search);
    })
    ->get();

The authorized tenant identifier must come from an authenticated, authorized context. Do not treat a tenant ID supplied by the browser as authority.

The intended logic is: this tenant AND either matching number OR matching reference. Parentheses matter because the tenant condition should apply to both alternatives.

Test the dangerous match

Create two tenants. Put an invoice in the second tenant whose reference matches the first tenant's search. Assert that the first tenant sees no such invoice. Then add a same-tenant number match and reference match to establish the intended positive behavior.

This test catches the actual isolation failure. A single-tenant fixture cannot reveal it.

Keep authorization outside the search string

Parameter binding handles values, not the business permission to read them. Also allowlist any requested sort column: a bound search value does not make arbitrary column names safe.

If the model already has tenant scopes, preserve them and test the resulting behavior. A scope is useful centralization, but it does not excuse unsafe raw SQL or ungrouped logical alternatives elsewhere.

Laravel's logical grouping documentation shows grouped conditions. The customer-isolation example here is a test strategy for applying that feature to an authorization boundary.