A Laravel legal-case app isolates law firms with an Eloquent global scope; which code paths still leak or lose matters, and how do you guard them?
answer
- DB facade skips Eloquent scopes
- builder forceDelete() applies no scopes
- no user in queues and console
- scope on User can recurse
- audit every withoutGlobalScopes()
basics
~20 sDB::table() and raw SQL skip the scope; a builder-level forceDelete() runs with no global scopes; a scope reading auth() finds no user in jobs and commands; and withoutGlobalScopes() calls spread. Pass firm context explicitly, fail closed without it, and test cross-firm access.
solid answer
~50 sThe scope lives in the Eloquent builder, so `DB::table('matters')`, raw `DB::select()` and anything built on the base query builder skip it. A builder-level `Matter::where(...)->forceDelete()` deletes through the base query **without applying any global scope** — the source says so — so it can delete other firms' rows. A scope that reads `auth()->user()` has no user in queue workers, scheduled commands and tinker: if it skips filtering when the user is null, jobs see every firm; if it filters by `null`, they see nothing. A firm scope on the `User` model that calls `auth()->user()` can recurse, because resolving the user runs a `User` query. Guard by resolving the firm from an explicit context service set by middleware and by jobs, throwing when it is missing, qualifying the column, reviewing every `withoutGlobalScope()`, and keeping a test that seeds two firms and asserts isolation.
code
php · 23 lines<?php
namespace App\Models\Scopes;
use App\Support\CurrentFirm;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
use LogicException;
class FirmScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$firm = app(CurrentFirm::class);
if (! $firm->isSet()) {
throw new LogicException('No firm context for '.$model::class.' query.');
}
$builder->where($model->qualifyColumn('firm_id'), $firm->id());
}
}go deeper
Recall that the firm scope only filters Eloquent model queries and that DB facade queries need their own condition.
Explain which paths skip scopes, including builder forceDelete(), and why removing one scope by name is safer than removing all.
Design fail-closed firm context for requests, jobs and commands, avoid User-model recursion, and back isolation with cross-firm tests.
Decide when shared-table tenancy with scopes is enough and when isolation needs separate databases or database-level policies.
## The setup Each `Matter`, `Document` and `TimeEntry` row carries a `firm_id`, and a `FirmScope` adds `where firm_id = ?` to their Eloquent queries. The design is sound; the leaks come from paths that do not go through that builder, and from the **context** the scope depends on. ## Paths that skip the scope | Path | Why it skips | Guard | |---|---|---| | `DB::table('matters')`, `DB::select(...)` | base query builder, no model, no scopes | ban for tenant tables, or add the firm condition by hand | | `Matter::where(...)->forceDelete()` | Eloquent's builder `forceDelete()` runs the base query directly, without applying scopes | load and delete models, or add the firm condition explicitly | | `withoutGlobalScopes()` in shared code | removes the tenant scope along with the one you meant | remove by name: `withoutGlobalScope(SoftDeletingScope::class)` | | Database-level features (views, triggers, reports run in SQL tools) | outside the application | enforce with database permissions if required | The `forceDelete()` case surprises people. The framework's builder calls the underlying query's `delete()` without the scope application that `get()`, `update()` and `delete()` go through; its own docblock says scopes are not applied so that the row is really deleted. A cleanup job written as `Matter::onlyTrashed()->where('closed_at', '<', $cutoff)->forceDelete()` therefore hard-deletes expired trashed matters for **every** firm. Conditions added directly to the query survive — the `where`, local scopes, and `onlyTrashed()`, which removes the soft-delete scope and adds its own `whereNotNull` — but every **global** scope, the firm filter included, is skipped. ## Context that is missing or stale A firm scope needs to know the current firm. Where it gets it decides whether it is safe: 1. **Reading `auth()->user()` inside `apply()`.** In a queue worker, a scheduled command or tinker, there is no authenticated user. A scope that skips filtering when the user is `null` fails **open**: every firm's data flows into the job. A scope that filters `where firm_id = null` fails **closed** and finds nothing, which is safer but confusing. 2. **Capturing the firm at registration.** Scopes are registered once per class, so a firm captured then is reused for later queries in the same process. 3. **Scoping the `User` model by the logged-in user.** Resolving the logged-in user runs a `User` query, which applies the same scope, which asks for the logged-in user again. The result can be infinite recursion or a user that never resolves. A robust design uses an explicit context object, for example a `CurrentFirm` service bound per request: - middleware sets it from the authenticated user or the subdomain; - a queued job carries the firm ID in its payload and sets it in `handle()`; - `apply()` **throws** when no firm is set, unless code has deliberately entered a documented "all firms" mode. ## Writes that land in the wrong firm A global scope filters queries; it does not fill `firm_id` on new rows. Set it from the same context service in a `creating` model hook, so `Matter::create([...])` cannot produce a row with no firm. ## Making it testable - Seed two firms with overlapping data, act as a user of the first, and assert every endpoint returns none of the second firm's rows. - Grep for `DB::table(`, `withoutGlobalScopes(` and `->forceDelete()` on tenant models in review, or add a static-analysis rule. - Log or alert on queries against tenant tables that carry no `firm_id` condition, if the database layer allows it. The model is simple — Eloquent adds the scope when it builds a model query — and the guard follows from it: every path that is not a model query, or that runs without context, needs its own check.
- How should a queued job that processes one firm's matters get its firm context?Put the firm ID in the job's constructor so it is serialized into the payload, and set the context service at the start of `handle()`. Do not rely on `auth()` inside the worker: the job runs later in another process with no authenticated user, and a fail-open scope would then return every firm's rows.
- Why prefer withoutGlobalScope(SoftDeletingScope::class) over withoutGlobalScopes() when you only want trashed rows?`withoutGlobalScopes()` with no argument removes every global scope, including the firm filter. Naming the one scope to remove, or simply calling `withTrashed()`, keeps tenant isolation in place. Blanket removal is the most common way a tenant scope disappears during unrelated work.
saying these in an interview costs you the question
- Assuming a builder forceDelete() still applies the firm scope
- Letting the scope skip filtering when no user is logged in
- Believing DB::table() queries on matters are firm-filtered
- Expecting the global scope to fill firm_id on new rows
- Using withoutGlobalScopes() to include soft-deleted rows