<?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);
}
}
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProductFilterRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'q' => ['nullable', 'string', 'max:100'],
'category' => ['nullable', 'array'],
'category.*' => ['integer', 'min:1'],
'min' => ['nullable', 'numeric', 'min:0'],
'max' => ['nullable', 'numeric', 'gte:min'],
'sort' => ['nullable', Rule::in([
'newest', 'oldest', 'price_asc', 'price_desc', 'name',
])],
'per_page' => ['nullable', 'integer', 'between:1,100'],
];
}
protected function prepareForValidation(): void
{
$term = trim((string) $this->input('q', ''));
$this->merge([
'q' => $term === '' ? null : $term,
]);
}
}
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ProductFilterRequest;
use App\Models\Product;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class ProductController extends Controller
{
public function index(ProductFilterRequest $request): LengthAwarePaginator
{
$filters = $request->validated();
$products = Product::query()
->where('active', true)
->search($filters['q'] ?? null)
->inCategory($filters['category'] ?? null)
->priceBetween($filters['min'] ?? null, $filters['max'] ?? null)
->applySort($filters['sort'] ?? null)
->paginate($filters['per_page'] ?? 20);
return $products->appends($request->query());
}
}
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
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;
Laravel service container and dependency injection
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
{
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build"
},
Laravel mix/Vite for asset compilation
Share this code
Here's the card — post it anywhere.