php 110 lines · 3 tabs

Building Filterable, Sortable Product Listings with Laravel Query Scopes

Shared by codesnips Aug 2026
3 tabs
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

class Product extends Model
{
    protected $fillable = ['name', 'price', 'category_id', 'active'];

    protected array $sortable = [
        'newest'     => ['created_at', 'desc'],
        'oldest'     => ['created_at', 'asc'],
        'price_asc'  => ['price', 'asc'],
        'price_desc' => ['price', 'desc'],
        'name'       => ['name', 'asc'],
    ];

    public function scopeSearch(Builder $query, ?string $term): Builder
    {
        return $query->when($term, function (Builder $q, string $term) {
            $q->where('name', 'like', '%' . $term . '%');
        });
    }

    public function scopeInCategory(Builder $query, $categories): Builder
    {
        return $query->when($categories, function (Builder $q, $categories) {
            $q->whereIn('category_id', (array) $categories);
        });
    }

    public function scopePriceBetween(Builder $query, $min, $max): Builder
    {
        return $query
            ->when($min !== null, fn (Builder $q) => $q->where('price', '>=', $min))
            ->when($max !== null, fn (Builder $q) => $q->where('price', '<=', $max));
    }

    public function scopeApplySort(Builder $query, ?string $sort): Builder
    {
        [$column, $direction] = $this->sortable[$sort] ?? $this->sortable['newest'];

        return $query->orderBy($column, $direction);
    }
}
3 files · php Explain with highlit

This snippet shows how a product listing endpoint turns untrusted request parameters into safe, composable Eloquent queries using local query scopes. The pattern keeps filtering and sorting logic on the model where it can be reused and unit-tested, instead of scattering where clauses across controllers.

In Product model, each filter is expressed as a scope* method: scopeSearch guards against blank input with when() so an empty search string is a no-op, scopeInCategory accepts either a single id or an array via whereIn, and scopePriceBetween conditionally applies a floor and ceiling independently. The crucial piece is scopeApplySort, which never trusts the raw sort value — it maps a small whitelist of public keys (newest, price_asc) to real columns and directions through $sortable. Anything outside the map falls back to a deterministic default, which closes the door on SQL-injection-by-column-name and on invalid-column errors.

ProductFilterRequest is a form request that validates and normalizes params before they ever reach the model. Declaring min and max as numeric and category as an array of integers means the controller can assume clean types. prepareForValidation trims the search term so whitespace-only queries behave like no query, and the sort rule uses Rule::in against the same whitelist the model understands, keeping the two layers in agreement.

ProductController is deliberately thin: it reads the validated data, pipes each value into the matching scope, and returns a paginated result. Because scopes return the builder, they chain fluently and only the filters that were actually supplied contribute clauses. Calling appends($request->query()) on the paginator preserves the active filters across page links, so pagination and filtering compose correctly in the rendered links.

The trade-off is a little indirection — the sort whitelist lives in two places — but it buys safety, readability, and reuse. This approach fits any list screen with faceted filters, and scales cleanly: adding a new facet means one scope plus one validation rule, with no controller churn.


Related snips

Share this code

Here's the card — post it anywhere.

Building Filterable, Sortable Product Listings with Laravel Query Scopes — share card
Link copied