Skip to content
ShapppesLog inSign up

Shapppes - Create, share, and discover stories

Laravel Query Filtering

Discover how to implement efficient and maintainable filtering in Laravel applications. Learn best practices for clean, reusable code that enhances your query-building capabilities.

3 min read

Introduction

In most Laravel applications, filtering data is a crucial requirement, whether for refining search results or displaying dynamic content. However, implementing complex filtering logic can quickly become unmanageable if not structured properly.

In this article, we’ll explore a flexible and maintainable approach to handling complex filtering in Laravel applications. By following best practices and leveraging Laravel's powerful query-building capabilities, you'll learn how to design an efficient filtering system that keeps your code clean, reusable, and easy to extend.

Imagine we have an articles table in our back office, and we need to filter the table by passing filter values through the URL. This would result in a URL like the following :

http://www.website.com/articles?status=published&user=1&category=2&start_date=2025-04-03T13%3A19

First solution

A initial approach is to directly add where clauses in the controller, checking if the request parameters are filled before applying filters.

namespace App\Http\Controllers;

use App\Models\Article;
use Illuminate\Http\Request;
use Illuminate\View\View;

class ArticleController extends Controller
{
    public function index(Request $request): View
    {
        $articles = Article::query();

        if ($request->filled('status')) {
            $articles->where('status', $request->input('status'));
        }

        if ($request->filled('user')) {
            $articles->where('user_id', $request->input('user'));
        }

        if ($request->filled('category')) {
            $articles->where('category_id', $request->input('category'));
        }

        if ($request->filled('start_date')) {
            $articles->where('created_at', '>=', $startDate);
        }

        if ($request->filled('end_date')) {
            $articles->where('created_at', '<=', $endDate);
        }

        return view('index', [
            'articles' => $articles->paginate(10)
        ]);
    }

}

This approach work but it’s not reusable and requires repeating the same conditional logic for each filter. With 10 or 15 filters, the code quickly becomes less readable.

Second solution

Another approach is to simplify conditional statements by using the when method directly on the query builder.

namespace App\Http\Controllers;

use App\Models\Article;
use Illuminate\Http\Request;
use Illuminate\View\View;

class ArticleController extends Controller
{
    public function index(Request $request): View
    {
        $articles = Article::query()
            ->when($request->filled('status'), function ($query) use ($request) {
                $query->where('status', $request->input('status'));
            })
            ->when($request->filled('user_id'), function ($query) use ($request) {
                $query->where('user_id', $request->input('user_id'));
            })
            ->when($request->filled('category_id'), function ($query) use ($request) {
                $query->where('category_id', $request->input('category_id'));
            })
            ->when($request->filled('start_date'), function ($query) use ($request) {
                $query->whereFuture('created_at');
            })
            ->when($request->filled('end_date'), function ($query) use ($request) {
                $query->wherePast('created_at');
            });

        return view('index', [
            'articles' => $articles->paginate(10)
        ]);
    }
}

This method eliminates the need for multiple if statements but still suffers from the same issues as the first approach.

Best approach

First create Filterable trait, that can be used in within your models.

<?php

namespace App\Models\Traits;

use Illuminate\Database\Eloquent\Builder;

trait Filterable
{
    /**
     * Apply all relevant filters.
     *
     * @param Builder $query
     * @return Builder
     */
    public function scopeFilter(Builder $query) : Builder
    {
        $filterClass = 'App\\Filters\\' .class_basename($this). 'Filters';

        $filterClass = new $filterClass();

        return $filterClass->apply($query);
    }
}

Next, use this trait in your Article Model.

<?php

namespace App\Models;

use App\Models\Traits\Filterable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Article extends Model
{
    use HasFactory, Filterable;

    protected $guarded = ['id'];

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function category(): BelongsTo
    {
        return $this->belongsTo(Category::class);
    }
}

After that, create a filter class based on model name (ArticleFilters). In this class, define a separate method for each filter, ensuring that the method name matches the filter name parameter passed in the query.

<?php

namespace App\Filters;

use Illuminate\Database\Eloquent\Builder;

class ArticleFilters extends Filter
{
    public function status(string $status): Builder
    {
        return $this->builder->where('status', $status);
    }

    public function user(int $user): Builder
    {
        return $this->builder->where('user_id', $user);
    }

    public function category(int $category): Builder
    {
        return $this->builder->where('category_id', $category);
    }

    public function startDate(string $startDate): Builder
    {
        return $this->builder->where('created_at', '>=', $startDate);
    }

    public function endDate(string $endDate): Builder
    {
        return $this->builder->where('created_at', '<=', $endDate);
    }
}

This filter class should inherit from an abstract base class that defines the structure for handling filtering logic consistently across different models.

<?php

namespace App\Filters;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;

abstract class Filter
{
    public function receivedFilters(): array
    {
        $filters = request()->query();

        $clear = request()->exists('clear');

        return $clear ? [] : $filters;
    }

    public function apply(Builder $builder) : Builder
    {
        foreach ($this->receivedFilters() as $name => $value) {

            $this->builder = $builder;

            $method = Str::camel($name);

            if (method_exists($this, $method)) {
                call_user_func_array([$this, $method], [$value]);
            }
        }

        return $builder;
    }
}

This class serves as the core of the filtering logic. It retrieves all query filters and dynamically calls the corresponding method for each filter, passing the filter value as an argument. This approach dynamically create query by applying where clauses to the query builder.

Finally in our controller we can now simplify our code by using the filter scope on your model.

<?php

namespace App\Http\Controllers;

use App\Models\Article;
use Illuminate\View\View;

class ArticleController extends Controller
{
    public function index(): View
    {
        return view('index', [
            'articles' => Article::query()->filter()->paginate(10)
        ]);
    }
}

Using this approach, we only need to add a new method to the ArticleFilters class whenever we need to implement an additional filter for the articles.