Data filtering and searching is a very common need in web applications — from product filters in e-commerce to report searches in management systems. In Laravel, there are several ways to implement it, from the simplest to the most structured. This article will teach you a clean, easily extensible data filter pattern that doesn't clutter the controller.
The Simple Approach: Filter Directly in the Controller
The most basic way is to use conditionals in the query builder. Suitable for filters that aren't too numerous:
<?php
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\Request;
class ProductController extends Controller
{
public function index(Request $request)
{
$query = Product::query();
// Filter by name (search)
if ($request->filled('search')) {
$query->where('name', 'like', '%' . $request->search . '%');
}
// Filter by category
if ($request->filled('category_id')) {
$query->where('category_id', $request->category_id);
}
// Filter by price range
if ($request->filled('min_price')) {
$query->where('price', '>=', $request->min_price);
}
if ($request->filled('max_price')) {
$query->where('price', '<=', $request->max_price);
}
// Sorting
$sortBy = $request->get('sort_by', 'created_at');
$sortDir = $request->get('sort_dir', 'desc');
$allowedSorts = ['name', 'price', 'created_at'];
if (in_array($sortBy, $allowedSorts)) {
$query->orderBy($sortBy, $sortDir === 'asc' ? 'asc' : 'desc');
}
$products = $query->paginate(15)->withQueryString();
return view('products.index', compact('products'));
}
}
Creating the Filter Form in Blade
<form method="GET" action="{{ route('products.index') }}">
<input type="text" name="search" value="{{ request('search') }}" placeholder="Search products...">
<select name="category_id">
<option value="">All Categories</option>
@foreach($categories as $cat)
<option value="{{ $cat->id }}" {{ request('category_id') == $cat->id ? 'selected' : '' }}>
{{ $cat->name }}
</option>
@endforeach
</select>
<input type="number" name="min_price" value="{{ request('min_price') }}" placeholder="Min price">
<input type="number" name="max_price" value="{{ request('max_price') }}" placeholder="Max price">
<button type="submit">Filter</button>
<a href="{{ route('products.index') }}">Reset</a>
</form>
Use withQueryString() on the paginator so the filter parameters persist when moving between pages.
A Cleaner Approach: Local Scopes in the Model
For reusable filters, use Eloquent local scopes:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
public function scopeSearch(Builder $query, ?string $search): Builder
{
return $query->when($search, function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
});
}
public function scopeCategory(Builder $query, ?int $categoryId): Builder
{
return $query->when($categoryId, fn($q) => $q->where('category_id', $categoryId));
}
public function scopePriceRange(Builder $query, ?float $min, ?float $max): Builder
{
return $query
->when($min, fn($q) => $q->where('price', '>=', $min))
->when($max, fn($q) => $q->where('price', '<=', $max));
}
}
Usage in the controller becomes very clean:
$products = Product::query()
->search($request->search)
->category($request->category_id)
->priceRange($request->min_price, $request->max_price)
->latest()
->paginate(15)
->withQueryString();
The Professional Approach: A Separate Filter Class
For large-scale applications with many filters, create a dedicated filter class so the controller stays thin:
<?php
// app/Filters/ProductFilter.php
namespace App\Filters;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
class ProductFilter
{
public function __construct(protected Request $request) {}
public function apply(Builder $query): Builder
{
if ($this->request->filled('search')) {
$search = $this->request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('sku', $search);
});
}
if ($this->request->filled('category_id')) {
$query->where('category_id', $this->request->category_id);
}
if ($this->request->filled('min_price')) {
$query->where('price', '>=', $this->request->min_price);
}
if ($this->request->filled('max_price')) {
$query->where('price', '<=', $this->request->max_price);
}
if ($this->request->filled('in_stock')) {
$query->where('stock', '>', 0);
}
return $query;
}
}
Use it in the controller with injection:
use App\Filters\ProductFilter;
public function index(Request $request, ProductFilter $filter)
{
$products = $filter->apply(Product::query())
->latest()
->paginate(15)
->withQueryString();
return view('products.index', compact('products'));
}
Preserving Filter State in the View
Make sure the filter values stay filled in after the form is submitted by using the request() helper:
<input type="text"
name="search"
value="{{ request('search') }}"
placeholder="Search by name or SKU...">
Conclusion
Choose the approach that fits your project's complexity. For simple filters, doing it directly in the controller is enough. For more complex and reusable ones, use local scopes in the model. If there are already very many filters that need to be used in many places, separate them into a dedicated Filter class. With the right pattern, your filter feature will be easy to extend and maintain without cluttering code everywhere.