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/
The Fallacy of the “Convenient” Model Scope
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);
}
}
PHPIt works. It’s expressive enough in the controller: Customer::dormantHighValueB2b()->get(). But consider the structural consequences over time:
- Violation of Single Responsibility Principle (SRP): The
Customermodel is now responsible for database mapping, attribute casting, relationship definitions, business rules, AND specific reporting analytics filters. - 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+1query patterns or conflictingWHEREclauses that silently override each other. - Zero Encapsulation for Complex Returns: Scopes return an
Illuminate\Database\Eloquent\Builderinstance. This means any caller can keep chaining arbitrary SQL onto the end of your query, leaking implementation details across controller actions or job handlers. - 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.
Enter Query Objects
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);
}
}
PHPNotice 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);
}
}
PHPRepositories vs. Query Objects: Clearing the Confusion
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.
When SHOULD You Still Use Local Query Scopes?
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.
Key Takeaways for Your Codebase Today
- Keep Models Lean: Your Eloquent models should describe data structure, relationships, and basic mutations—not multi-table analytical queries.
- One Query, One Class: When a query spans multiple relationships or complex conditionals, create a dedicated class under
App\Queries. - Avoid Mega-Repositories: Don’t trade Fat Models for Fat Repositories. Use invokable, single-purpose Query Objects for complex reads.
- 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.

