The Problem: Scattered Conditionals in Query Land
Every sufficiently complex Laravel application eventually grows a scopeVisible, scopeForTenant, or scopeAccessible that reads from auth()->user() or app('context') inside the model. It works — until you need to test it in isolation, swap the context in a job, or reuse the same model under different access rules in the same request.
The real issue is that the who is asking logic bleeds into the what to fetch logic. This article shows a clean way to separate them using contextual binding and a tiny resolver contract.
Defining a Query Context Contract
Start with a small interface that any "context" must satisfy:
namespace App\Contracts;
use Illuminate\Database\Eloquent\Builder;
interface AppliesQueryContext
{
public function apply(Builder $query): Builder;
}
Now create a concrete implementation for the authenticated user context:
namespace App\Query\Contexts;
use App\Contracts\AppliesQueryContext;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
final class UserQueryContext implements AppliesQueryContext
{
public function __construct(private readonly User $user) {}
public function apply(Builder $query): Builder
{
return $query->where('team_id', $this->user->team_id)
->where('is_archived', false);
}
}
Binding the Context in a Service Provider
Register the binding in AppServiceProvider (or a dedicated QueryContextServiceProvider):
$this->app->scoped(AppliesQueryContext::class, function () {
$user = auth()->user();
if (! $user) {
return new NullQueryContext(); // no-op implementation
}
return new UserQueryContext($user);
});
scoped() ensures the same instance is reused for the entire request or job lifecycle — critical for consistency and performance.
A Trait That Wires It Into Eloquent
namespace App\Concerns;
use App\Contracts\AppliesQueryContext;
use Illuminate\Database\Eloquent\Builder;
trait HasQueryContext
{
public function scopeWithContext(Builder $query): Builder
{
return app(AppliesQueryContext::class)->apply($query);
}
}
Add the trait to any model:
class Post extends Model
{
use HasQueryContext;
}
Now callers write:
Post::withContext()->latest()->paginate();
No auth() calls inside the model. No hidden globals.
Swapping Context in Jobs and Tests
In a queued job that runs on behalf of a specific team, rebind before dispatching work:
app()->instance(
AppliesQueryContext::class,
new TeamQueryContext($team)
);
In Pest, override the binding per test:
it('only returns posts for the correct team', function () {
$team = Team::factory()->create();
$other = Team::factory()->create();
Post::factory()->for($team)->count(3)->create();
Post::factory()->for($other)->count(2)->create();
app()->instance(
AppliesQueryContext::class,
new UserQueryContext(User::factory()->for($team)->make())
);
expect(Post::withContext()->count())->toBe(3);
});
No actingAs, no session bootstrapping — just a clean container swap.
Composing Multiple Contexts
For multi-tenant SaaS where you need both tenant isolation and soft-delete filtering, compose contexts:
final class CompositeQueryContext implements AppliesQueryContext
{
/** @param AppliesQueryContext[] $contexts */
public function __construct(private array $contexts) {}
public function apply(Builder $query): Builder
{
foreach ($this->contexts as $context) {
$query = $context->apply($query);
}
return $query;
}
}
Bind it once in the provider and every model using HasQueryContext gets all rules applied automatically.
Key Takeaways
- Use
scoped()bindings so the context is resolved once per request/job, not on every query. - A
NullQueryContextno-op keeps unauthenticated paths safe without null checks. - The
withContext()scope is opt-in — models that don't need it stay untouched. - Composing contexts scales cleanly to multi-tenant + role-based + soft-delete rules.
- Swapping the binding in tests eliminates the need for HTTP-layer setup when testing query logic.