DEV Community

Cover image for Eloquent Filtering Gets Messy Fast. Here’s What Holds Up.
Saqueib Ansari
Saqueib Ansari

Posted on • Originally published at qcode.in

Eloquent Filtering Gets Messy Fast. Here’s What Holds Up.

Flexible Eloquent filtering usually dies the same way: a clean index() endpoint turns into fifteen if ($request->filled(...)) branches, three whereHas() chains, a half-working date range, and one emergency orWhere() that quietly breaks tenant isolation.

The fix is not “use a filtering package” or “keep it simple.” The real fix is choosing the right filtering shape for the kind of API you are building. Laravel gives you enough rope here. Eloquent scopes, query objects, package-driven filters, JSON filters, and relationship filters all work. They just stop working at different complexity levels.

My recommendation is straightforward: use local scopes for reusable model-level constraints, use explicit query objects for serious internal APIs, and use a package like Spatie Query Builder only when you actually want request-driven filtering semantics at the HTTP boundary. Do not let controller methods become your query language.

Start with the failure mode, not the syntax

Most teams don’t choose query spaghetti on purpose. They get there incrementally.

A product listing starts with status and category filters. Then search gets added. Then tags. Then "only products with active discounts." Then sorting. Then a partner integration wants created_from and created_to. Then someone asks for ?brand=nike,adidas because that feels natural in a URL.

The code often ends up looking like this:

public function index(Request $request)
{
    $query = Product::query()->where('tenant_id', tenant()->id);

    if ($request->filled('status')) {
        $query->where('status', $request->string('status'));
    }

    if ($request->filled('category')) {
        $query->whereHas('category', function ($q) use ($request) {
            $q->where('slug', $request->string('category'));
        });
    }

    if ($request->filled('search')) {
        $search = $request->string('search');
        $query->where(function ($q) use ($search) {
            $q->where('name', 'like', "%{$search}%")
              ->orWhere('sku', 'like', "%{$search}%");
        });
    }

    if ($request->boolean('discounted')) {
        $query->whereHas('discounts', fn ($q) => $q->where('active', true));
    }

    if ($request->filled('created_from')) {
        $query->whereDate('created_at', '>=', $request->date('created_from'));
    }

    if ($request->filled('created_to')) {
        $query->whereDate('created_at', '<=', $request->date('created_to'));
    }

    return ProductResource::collection(
        $query->latest()->paginate()
    );
}
Enter fullscreen mode Exit fullscreen mode

Nothing in that method is individually outrageous. The problem is architectural. The controller is now doing four jobs:

  • parsing HTTP input
  • defining business filtering rules
  • composing SQL constraints
  • hiding edge cases nobody wants to touch

That structure is cheap on day one and expensive forever after. It gets harder to test, easier to break, and nearly impossible to reuse from jobs, commands, admin screens, or other services.

The first decision, then, is not which package to install. It is this: is your filtering logic request-shaped, domain-shaped, or both?

Scopes are excellent until they become a language

Laravel’s local scopes are still the cleanest starting point for common reusable constraints. If you have one meaningful business concept, a scope is usually the right answer.

class Product extends Model
{
    #[Scope]
    protected function active(Builder $query): void
    {
        $query->where('status', 'active');
    }

    #[Scope]
    protected function forTenant(Builder $query, int $tenantId): void
    {
        $query->where('tenant_id', $tenantId);
    }

    #[Scope]
    protected function inCategory(Builder $query, string $slug): void
    {
        $query->whereHas('category', fn (Builder $q) => $q->where('slug', $slug));
    }
}
Enter fullscreen mode Exit fullscreen mode

That gives you readable query code:

Product::query()
    ->forTenant($tenantId)
    ->active()
    ->inCategory('laptops')
    ->latest()
    ->paginate();
Enter fullscreen mode Exit fullscreen mode

This is where scopes win:

  • the condition has a clear domain meaning
  • you will reuse it in multiple places
  • it composes cleanly with other builder calls
  • the parameter shape is small and obvious

This is where scopes start losing:

  • you need ten optional filters from one request
  • filters interact with each other
  • you need branching logic based on user role, feature flags, or API version
  • you are effectively inventing a mini query DSL

A scope should represent one stable concept, not half a search form. active(), forTenant(), visibleToUser() and withPublishedPosts() are good scopes. filterFromRequest() is a code smell wearing Laravel clothes.

Another common mistake is pushing too much parsing into scopes. If a scope is exploding CSV values, reading request keys, and inferring boolean semantics, you are no longer modeling data access cleanly. You are smuggling transport concerns into the model.

Rule of thumb: if the scope name reads well in a sentence, it probably belongs. If it reads like URL syntax, it probably does not.

Query objects are the best default for serious APIs

Once an endpoint supports multiple optional filters, explicit query objects usually beat every other pattern on maintainability.

Why? Because they separate responsibilities cleanly. The request object validates input. A filter DTO normalizes it. A query object applies database constraints. Your controller becomes boring again, which is what you want.

Here is a practical shape that holds up well:

final readonly class ProductFilters
{
    public function __construct(
        public ?string $status,
        public ?string $category,
        public ?string $search,
        public ?bool $discounted,
        public ?Carbon $createdFrom,
        public ?Carbon $createdTo,
    ) {}

    public static function fromRequest(Request $request): self
    {
        return new self(
            status: $request->string('status')->toString() ?: null,
            category: $request->string('category')->toString() ?: null,
            search: $request->string('search')->toString() ?: null,
            discounted: $request->has('discounted') ? $request->boolean('discounted') : null,
            createdFrom: $request->date('created_from'),
            createdTo: $request->date('created_to'),
        );
    }
}

final class ProductIndexQuery
{
    public function apply(Builder $query, ProductFilters $filters): Builder
    {
        return $query
            ->when($filters->status, fn (Builder $q, string $status) => $q->where('status', $status))
            ->when($filters->category, fn (Builder $q, string $category) =>
                $q->whereHas('category', fn (Builder $categoryQuery) =>
                    $categoryQuery->where('slug', $category)
                )
            )
            ->when($filters->search, function (Builder $q, string $search) {
                $q->where(function (Builder $nested) use ($search) {
                    $nested->where('name', 'like', "%{$search}%")
                        ->orWhere('sku', 'like', "%{$search}%");
                });
            })
            ->when($filters->discounted === true, fn (Builder $q) =>
                $q->whereHas('discounts', fn (Builder $discounts) => $discounts->where('active', true))
            )
            ->when($filters->createdFrom, fn (Builder $q, Carbon $date) => $q->whereDate('created_at', '>=', $date))
            ->when($filters->createdTo, fn (Builder $q, Carbon $date) => $q->whereDate('created_at', '<=', $date));
    }
}
Enter fullscreen mode Exit fullscreen mode

Then your controller becomes small enough to trust:

public function index(ProductIndexRequest $request, ProductIndexQuery $productIndexQuery)
{
    $filters = ProductFilters::fromRequest($request);

    $products = $productIndexQuery
        ->apply(Product::query()->forTenant(tenant()->id), $filters)
        ->latest()
        ->paginate();

    return ProductResource::collection($products);
}
Enter fullscreen mode Exit fullscreen mode

This approach is not flashy. It is better than flashy.

Why query objects hold up better

First, they scale in plain PHP. You can unit test the filter object, feature test the endpoint, and integration test SQL-sensitive paths without pretending the controller is an architecture.

Second, they make unsafe logic easier to see. A loose orWhere() hidden inside nested conditions is much easier to spot in a dedicated query class than inside a 120-line action method.

Third, they are reusable. The same query object can power an API endpoint, an admin table, an export job, or a queued report.

If your team builds internal tools, back-office dashboards, or domain-heavy SaaS APIs, query objects are the most boring sustainable answer. That is a compliment.

Package-based filtering is great at the boundary, not in the core

If your API is intentionally request-driven, Spatie Laravel Query Builder is one of the few packages I would recommend without hand-waving. It gives you a controlled way to expose filtering, sorting, and includes from HTTP query parameters.

That matters because the package forces one discipline most homegrown filtering layers never get right: an explicit allowlist.

use Spatie\QueryBuilder\AllowedFilter;
use Spatie\QueryBuilder\QueryBuilder;

$products = QueryBuilder::for(Product::class)
    ->where('tenant_id', tenant()->id)
    ->allowedFilters([
        AllowedFilter::exact('status'),
        AllowedFilter::scope('active'),
        AllowedFilter::callback('category', function (Builder $query, $value) {
            $query->whereHas('category', fn (Builder $q) => $q->where('slug', $value));
        }),
        AllowedFilter::callback('search', function (Builder $query, $value) {
            $query->where(function (Builder $nested) use ($value) {
                $nested->where('name', 'like', "%{$value}%")
                    ->orWhere('sku', 'like', "%{$value}%");
            });
        }),
    ])
    ->allowedSorts(['created_at', 'price'])
    ->defaultSort('-created_at')
    ->paginate();
Enter fullscreen mode Exit fullscreen mode

For public or partner-facing APIs, that is a strong trade. You get predictable syntax like ?filter[status]=active, explicit surface area, and less controller glue.

But packages like this are not magic. They are best when the request itself is the product. They are less compelling when your filtering rules are mostly domain logic.

Where package filtering wins

  • public APIs with documented query parameters
  • index endpoints with many optional filters
  • teams that want consistency across resources
  • cases where sort/include/filter conventions should feel uniform

Where it becomes awkward

  • deeply coupled business rules
  • nontrivial authorization-driven filtering
  • multi-step query assembly across services
  • highly custom search behavior that stops looking like simple request filters

There is also a real footgun here: once the package feels convenient, teams start exposing more surface area than they should. Just because you can allow a field or relation does not mean the endpoint should support it.

The package should not become permission to turn your database schema into your API contract.

Strong recommendation: use package filtering at the HTTP edge, then keep domain-critical query logic behind scopes, query objects, or dedicated callbacks. Let the package route requests. Do not let it design your data access model.

Relationship and JSON filters are where clean code usually rots

This is the part most filtering tutorials underplay. Filtering gets ugly fastest when it crosses relationships or dives into semi-structured JSON columns.

Relationship filters with whereHas() are powerful, but they are also where query performance, readability, and accidental logic leaks start showing up.

$query->whereHas('orders', function (Builder $orders) use ($filters) {
    $orders->where('status', 'paid')
        ->when($filters->paidFrom, fn (Builder $q, Carbon $date) => $q->whereDate('paid_at', '>=', $date))
        ->when($filters->paidTo, fn (Builder $q, Carbon $date) => $q->whereDate('paid_at', '<=', $date));
});
Enter fullscreen mode Exit fullscreen mode

That is fine when it is deliberate. It becomes a mess when every new filter adds another nested closure and nobody reviews the resulting SQL.

JSON filters are even worse when used lazily. Yes, MySQL and PostgreSQL can query JSON. Yes, Eloquent supports JSON path syntax. That does not mean arbitrary user-facing filtering should live there.

$query->where('settings->notifications->email', true)
      ->whereJsonContains('settings->roles', 'editor');
Enter fullscreen mode Exit fullscreen mode

This is acceptable for a few stable flags. It is not a good foundation for a broad filtering API.

Why these filters become maintenance traps

  • they hide indexing problems until production traffic arrives
  • they make database portability harder
  • they encourage schema avoidance instead of schema design
  • they are easy to compose badly with orWhere and nested conditions
  • they are harder to explain and document than normal relational filters

If a filter matters enough to be exposed, measured, and depended on, it usually deserves a real column, a real relation, or a real query object path. JSON is a storage format, not a long-term filtering strategy.

The same principle applies to relationship-heavy filters. If your endpoint needs repeated cross-relation filtering, stop sprinkling whereHas() everywhere and give that behavior a name. Hide it behind a scope or query object method that states intent clearly.

The practical selection rule

You do not need one filtering pattern. You need a selection rule that keeps your codebase consistent.

Here is the rule I would use on a Laravel team today:

  1. Use local scopes for small, reusable, domain-named constraints.
  2. Use query objects when an endpoint has several optional filters or meaningful business rules.
  3. Use Spatie Query Builder when the API contract itself is request-driven and you want explicit HTTP filtering semantics.
  4. Use relationship filters deliberately, but hide repeated ones behind named abstractions.
  5. Use JSON filters sparingly, and treat them as a compromise, not a default.

If you already have a spaghetti controller, do not rewrite everything at once. Pull it apart in this order:

  • move obvious reusable rules into scopes
  • introduce a dedicated query object for the endpoint
  • move request parsing into validation or a filter DTO
  • only then decide whether package-level request filtering actually improves the API

That sequence matters. Teams often install a package first because it feels like progress. In many codebases, the real problem is not missing syntax. It is missing boundaries.

The best Eloquent filtering code is not the most clever. It is the code that still makes sense six months later when someone adds one more filter under deadline pressure. If your controller is becoming a query language, you are already late. Split the responsibilities, keep the HTTP layer honest, and make your filtering strategy explicit before the endpoint turns into something nobody wants to touch.


Read the full post on QCode: https://qcode.in/advanced-eloquent-filtering-without-turning-apis-into-query-spaghetti/

Top comments (0)