Refactoring the Database Layer: Replacing Query Scope Bloat with Repository/Query Objects

Every developer who has maintained a Laravel application past its second birthday knows the exact sequence of events. You start with a clean User or Order model. It handles basic authentication, a couple of relationships, and maybe a simple active status scope. It’s elegant, intuitive, and feels like the framework working for you.

Fast forward eighteen months. That same Order Eloquent model is now a 1,400-line monolith. Nestled between event listeners, accessor mutations, and custom attribute cast methods sit dozens of local query scopes: scopeWhereActive(), scopeWhereEligibleForDiscount(), scopePendingExport(), scopeWithRecentTransactions().

We call this Query Scope Bloat. On paper, query scopes are advertised as the standard Laravel way to encapsulate reusable query logic. In practice, when applied indiscriminately to complex domain models, they turn your database interactions into an unmaintainable, tightly coupled mess.

In this post, we’re going to walk through why query scopes break down at scale and how to refactor them into crisp, single-purpose Query Objects (and where traditional Repositories fit into the picture).

Last article in this category: https://codecraftdiary.com/2026/07/31/laravel-models-business-logic-hidden-workflows/

Let’s look at how query scope bloat sneaks into a codebase. Imagine an e-commerce platform where marketing wants to identify high-value B2B customers who haven’t placed an order in the last 90 days but have active subscriptions.

The path of least resistance is usually adding a scope directly to the Customer model:

// App\Models\Customer.php

class Customer extends Model
{
    public function scopeDormantHighValueB2b($query, int $daysThreshold = 90)
    {
        return $query->where('type', 'b2b')
            ->whereHas('subscriptions', function ($q) {
                $q->where('status', 'active');
            })
            ->whereDoesntHave('orders', function ($q) use ($daysThreshold) {
                $q->where('created_at', '>=', now()->subDays($daysThreshold));
            })
            ->having('total_lifetime_spend', '>', 10000);
    }
}
PHP

It works. It’s expressive enough in the controller: Customer::dormantHighValueB2b()->get(). But consider the structural consequences over time:

  1. Violation of Single Responsibility Principle (SRP): The Customer model is now responsible for database mapping, attribute casting, relationship definitions, business rules, AND specific reporting analytics filters.
  2. Unintended Side Effects & Hidden Joins: Scopes often chain sub-queries or eager loads. When a developer chains three different scopes together in a controller, they frequently create accidental N+1 query patterns or conflicting WHERE clauses that silently override each other.
  3. Zero Encapsulation for Complex Returns: Scopes return an Illuminate\Database\Eloquent\Builder instance. This means any caller can keep chaining arbitrary SQL onto the end of your query, leaking implementation details across controller actions or job handlers.
  4. Test Rigidity: To test this single query rule, you must instantiate the entire Eloquent model, set up database state for related subscriptions and orders, and run full integration tests every single time.

Before jumping straight to a heavy Repository pattern interface with 20 methods, there is a much cleaner, more idiomatic solution for complex read queries: Query Objects.

A Query Object is a dedicated, invokable class whose sole job is to construct and execute a single database query. It isolates query construction from your domain models and controllers, making your database layer modular and trivially testable.

Step 1: Extracting the Query to a Dedicated Object

Let’s refactor our dormant B2B customer query into an invokable Query Object:

namespace App\Queries;

use App\Models\Customer;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;

final class GetDormantHighValueB2bCustomersQuery
{
    public function __construct(
        private readonly int $daysThreshold = 90,
        private readonly int $minimumSpend = 10000
    ) {}

    public function handle(int $perPage = 25): LengthAwarePaginator
    {
        return $this->getBaseQuery()
            ->paginate($perPage);
    }

    public function getBaseQuery(): Builder
    {
        return Customer::query()
            ->where('type', 'b2b')
            ->whereHas('subscriptions', fn (Builder $q) => $q->where('status', 'active'))
            ->whereDoesntHave('orders', fn (Builder $q) => 
                $q->where('created_at', '>=', now()->subDays($this->daysThreshold))
            )
            ->where('total_lifetime_spend', '>=', $this->minimumSpend);
    }
}
PHP

Notice what changed here:

  • The query constraints live in an explicit file inside App\Queries.
  • Parameters like threshold days or minimum spend are strongly typed in the constructor.
  • The model class remains lightweight, focusing purely on schema definition and relationships.

Step 2: Consuming Query Objects in Action Classes or Controllers

Now, inside your controller, job, or CLI command, you simply inject or instantiate the Query Object:

namespace App\Http\Controllers;

use App\Queries\GetDormantHighValueB2bCustomersQuery;
use Illuminate\Http\JsonResponse;

class MarketingTargetController extends Controller
{
    public function index(): JsonResponse
    {
        $query = new GetDormantHighValueB2bCustomersQuery(
            daysThreshold: 60,
            minimumSpend: 15000
        );

        $customers = $query->handle(perPage: 50);

        return response()->json($customers);
    }
}
PHP

In the PHP community, the “Repository Pattern” was heavily pushed years ago as the ultimate solution to Eloquent coupling. Many developers created interface-heavy structures like CustomerRepositoryInterface with methods like find(), save(), getDormant(), getRecent(), getVip().

In 90% of Laravel projects, generic repositories turn into God Interfaces. They become a dump for every custom query the app will ever run.

Architectural Rule of Thumb:

Repositories should be used for write operations or domain-entity hydration where you need to completely decouple from ORM storage details.

Query Objects should be used for read operations, complex filtering, reports, and search queries.

By separating reads into Query Objects, you respect CQRS (Command Query Responsibility Segregation) light principles without adding overwhelming boilerplate.

Refactoring doesn’t mean deleting every single query scope from your application. Scopes are still fantastic for atomic, single-column boolean conditions that are ubiquitous across the domain.

Good candidates for query scopes:

  • scopePublished($query) -> $query->whereNotNull('published_at')
  • scopeWhereActive($query) -> $query->where('is_active', true)

Rule of thumb: If a scope requires joining multiple tables, complex grouping, or specific business analytics logic, pull it out into a Query Object immediately.

  1. Keep Models Lean: Your Eloquent models should describe data structure, relationships, and basic mutations—not multi-table analytical queries.
  2. One Query, One Class: When a query spans multiple relationships or complex conditionals, create a dedicated class under App\Queries.
  3. Avoid Mega-Repositories: Don’t trade Fat Models for Fat Repositories. Use invokable, single-purpose Query Objects for complex reads.
  4. Test in Isolation: Query Objects allow you to test complex query constraints with dedicated database factories without clogging your primary model test suites.

Tidying up your database layer isn’t about dogmatic architectural purity—it’s about making sure that six months from now, when marketing changes the criteria for “dormant customers,” you only have to edit a single 30-line file without breaking three other features.

Leave a Reply

Your email address will not be published. Required fields are marked *