Contextual Eloquent Scopes: Binding Query Logic to Domain State at Runtime
#laravel #eloquent #architecture #testing #service-container

Contextual Eloquent Scopes: Binding Query Logic to Domain State at Runtime

3 min read Mohamed Said Mohamed Said

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 NullQueryContext no-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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Why use `scoped()` instead of `singleton()` for the query context binding?
`scoped()` resets between requests in long-running runtimes like Octane, preventing one user's context from leaking into the next request. `singleton()` would persist for the entire worker lifetime.
Q02 Does adding `withContext()` to every query hurt performance?
No. The context object is resolved once per request from the container cache. The `apply()` call just adds WHERE clauses to the existing Builder instance — there is no extra query or reflection overhead.
Q03 Can this pattern work with Filament table queries?
Yes. Override `getEloquentQuery()` in your Filament Resource and call `parent::getEloquentQuery()->withContext()`. The context binding is already in the container at that point in the request lifecycle.

Continue reading

More Articles

View all