Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Filterable Laravel Package

thejano/laravel-filterable

Laravel Filterable offers a clean, reusable way to filter Eloquent models using query string parameters. Define filters as dedicated classes, keep controllers slim, and compose filters with support for relationships, scopes, validation, and custom logic.

View on GitHub
Deep Wiki
Context7

Getting Started

Install

composer require thejano/laravel-filterable

The package is small — no service provider registration gymnastics, no migrations. If a config file is published, do it now so you have something to diff against on upgrades:

php artisan vendor:publish --tag="laravel-filterable-config"

Read these three things first

  1. src/Traits/Filterable.php — the trait you put on models. It is the whole entry point.
  2. src/Abstracts/Filter.php and src/Interfaces/FilterInterface.php — the contract every filter implements.
  3. The README.md filter examples — the API surface is tiny enough that the README is effectively the docs.

Wire it into a model

use TheJano\LaravelFilterable\Traits\Filterable;

class Post extends Model
{
    use Filterable;

    protected array $filters = [
        'status'     => \App\Filters\StatusFilter::class,
        'created_at' => \App\Filters\DateRangeFilter::class,
        'q'          => \App\Filters\SearchFilter::class,
    ];
}

Write your first filter

namespace App\Filters;

use Illuminate\Database\Eloquent\Builder;
use TheJano\LaravelFilterable\Abstracts\Filter;
use TheJano\LaravelFilterable\Interfaces\FilterInterface;

class StatusFilter extends Filter implements FilterInterface
{
    public function filter(Builder $builder, mixed $value): Builder
    {
        return $builder->where('status', $value);
    }
}

Confirm the exact abstract class name and method signature against the installed version (vendor/thejano/laravel-filterable/src/) — method names have drifted across releases.

First use case

The 20-minute win is replacing a hand-rolled if ($request->has('status')) { ... } block in a controller index action:

public function index(Request $request)
{
    return Post::filter($request->query())->paginate(20);
}

The trait reads only keys present in $filters, so you can pass the raw query array without whitelisting by hand. That is the entire value proposition — don't expect more from it.

Implementation Patterns

Keep one filter per concern, one concern per filter. A StatusFilter that also handles is_published becomes untestable. Compose at the model level instead:

protected array $filters = [
    'status'     => StatusFilter::class,
    'published'  => PublishedFilter::class,
    'author'     => AuthorFilter::class,
];

Range / date filters. Date filtering is where naive packages break, so own it explicitly:

class DateRangeFilter extends Filter implements FilterInterface
{
    public function filter(Builder $builder, mixed $value): Builder
    {
        [$from, $to] = array_pad(explode(',', $value), 2, null);

        return $builder
            ->when($from, fn ($q) => $q->whereDate('created_at', '>=', $from))
            ->when($to,   fn ($q) => $q->whereDate('created_at', '<=', $to));
    }
}

Search filters should be explicit about which columns they touch. Never interpolate a column name from the query string.

class SearchFilter extends Filter implements FilterInterface
{
    public function filter(Builder $builder, mixed $value): Builder
    {
        return $builder->where(function ($q) use ($value) {
            $q->where('title', 'like', "%{$value}%")
              ->orWhere('body', 'like', "%{$value}%");
        });
    }
}

Relationship filters. Use whereHas, and eager-load separately — the filter should not mutate the load set:

class AuthorFilter extends Filter implements FilterInterface
{
    public function filter(Builder $builder, mixed $value): Builder
    {
        return $builder->whereHas('author', fn ($q) => $q->where('name', 'like', "%{$value}%"));
    }
}

Constructor-injected options. Because each entry in $filters is a class string, configurable filters need either constructor promotion with a default or a per-model subclass. Prefer a dedicated subclass — it keeps the model file self-documenting:

class PublishedFilter extends BooleanFilter
{
    protected string $column = 'published_at'; // or whatever the base expects
}

Wrap it in a FilterSet / FormRequest. For anything with more than ~4 filters, funnel through a request object so validation runs before the filter ever executes:

class PostIndexRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'status'     => ['sometimes', Rule::in(['draft', 'published', 'archived'])],
            'created_at' => ['sometimes', 'string', 'regex:/^\d{4}-\d{2}-\d{2}(,\d{4}-\d{2}-\d{2})?$/'],
            'q'          => ['sometimes', 'string', 'max:100'],
        ];
    }
}

Testing filters directly. Instantiate the filter and assert on the SQL or the result set — no HTTP round trip needed:

$query = (new StatusFilter)->filter(Post::query(), 'published');
$this->assertSame('published', $query->getBindings()[0]);

Prefer assertDatabaseHas-style assertions over string-matching SQL when the filter has any complexity.

Livewire / Inertia integration. Filters are stateless and query-array driven, which composes cleanly: persist the filter array in a Livewire property (public array $filters = []), pass it through, and the URL stays shareable.

Gotchas and Tips

Unknown keys are silently ignored. That is by design (the $filters map is the whitelist) but it means a typo in a frontend query param produces no error and no filtering. Validate the allowed keys in a FormRequest if you need a hard failure.

Empty-string values will filter. ?status= typically still reaches the filter and produces WHERE status = ''. Guard at the top of filters that can receive blanks:

if ($value === null || $value === '') {
    return $builder;
}

Boolean strings lie. ?published=false arrives as the string "false", which is truthy in PHP. Cast explicitly (filter_var($value, FILTER_VALIDATE_BOOLEAN)) rather than comparing to true.

Parameter name collisions. q, page, sort, per_page, and include are common across packages. If you also use a sortable/searchable package, namespace your filter keys (filter[status]) or you will fight over the query string.

Always start from query(). Chaining ::filter() onto a model that already has global scopes is fine, but reusing a builder across multiple filter() calls will accumulate WHERE clauses. Build a fresh query per request.

Bindings, not raw SQL. The value passed to your filter is user input. whereRaw("status = '$value'") defeats the entire purpose. If you need dynamic columns, map through a hardcoded array — never accept the column name from the request.

Debugging a filter that "does nothing." Check in this order: (1) the key exists in $filters on the model, (2) the class implements FilterInterface, (3) the method name matches the version's contract (dump get_class_methods() if unsure), (4) call ->toSql() and ->getBindings() inside the filter before returning.

LIKE '%term%' will not use an index. This is a database problem the package cannot solve. For anything above tens of thousands of rows, move search to a whereFullText on MySQL/Postgres or a Scout-backed index and keep the filter as a thin adapter.

Eager-loading is your responsibility. A relationship filter with whereHas does not load the relation. Pair ->filter(...) with ->with('author') in the controller or you get N+1 on the serialization step.

Extension point: if you need cross-cutting behaviour (logging every applied filter, timing them, or short-circuiting on a ?filter=none flag), the cleanest hook is a base Filter subclass of your own in app/Filters/BaseFilter.php that all your filters extend — do not patch the vendor trait, since this package is small and lightly maintained and you will need clean upgrades.

Versioning reality check. At 75 stars this is a single-maintainer package. Pin the major version in composer.json, skim the diff before upgrading, and be prepared to vendor the trait into app/Concerns/Filterable.php if the project becomes long-lived — the trait is short enough that forking it is a lower risk than a stalled dependency.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin