PHPackages                             pokhreldipesh/model-filter - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Database &amp; ORM](/categories/database)
4. /
5. pokhreldipesh/model-filter

ActiveLibrary[Database &amp; ORM](/categories/database)

pokhreldipesh/model-filter
==========================

A composable query filter system for Laravel Eloquent models. Define filters and sorting behavior on models, then apply them from request parameters.

v1.0.1(1mo ago)01↓50%MITPHPPHP ^8.3

Since Jun 11Pushed 1mo agoCompare

[ Source](https://github.com/pokhreldipesh/model-filter)[ Packagist](https://packagist.org/packages/pokhreldipesh/model-filter)[ RSS](/packages/pokhreldipesh-model-filter/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (2)Dependencies (10)Versions (3)Used By (0)

ModelFilter
===========

[](#modelfilter)

A composable query filter system for Laravel Eloquent models. Define filters and sorting behavior directly on your models, then apply them from request parameters with zero boilerplate.

Requirements
------------

[](#requirements)

- PHP 8.3+
- Laravel 13+

Installation
------------

[](#installation)

```
composer require pokhreldipesh/model-filter
```

The service provider is auto-discovered. To publish the config:

```
php artisan vendor:publish --tag=model-filter-config
```

Quick Start
-----------

[](#quick-start)

### 1. Add the trait to your model

[](#1-add-the-trait-to-your-model)

```
use Dipeshpokhrel\ModelFilter\HasQueryFilters;

class Post extends Model
{
    use HasQueryFilters;

    public function filters(): array
    {
        return [
            $this->textSearch('q', ['title', 'body']),
            $this->exactMatch('status_id'),
            $this->relationshipExists('author_id')->relation('author'),
        ];
    }

    public function allowedSorts(): array
    {
        return ['title', 'created_at', 'views_count'];
    }

    public function defaultSort(): string
    {
        return 'created_at';
    }
}
```

### 2. Use the scopes in your controller

[](#2-use-the-scopes-in-your-controller)

```
public function index(Request $request)
{
    return PostResource::collection(
        Post::with('author')
            ->applyFilter($request)
            ->applySort($request)
            ->paginate($request->input('per_page', 15)),
    );
}
```

### 3. Make a request

[](#3-make-a-request)

```
GET /api/v1/posts?q=laravel&status_id=1&author_id=5&sort_by=title&sort_dir=desc&per_page=20

```

Filter Types
------------

[](#filter-types)

### TextSearch

[](#textsearch)

Search across one or more columns using `ilike` (PostgreSQL) or `like` (MySQL/SQLite). Auto-detects the operator based on your database driver. Skips when the value is an empty string.

```
use Dipeshpokhrel\ModelFilter\Filters\TextSearch;

// Single column (defaults to the parameter name as column)
TextSearch::make('q')

// Multiple columns
TextSearch::make('q')->columns(['title', 'isbn', 'description'])

// Force a specific operator
TextSearch::make('q')->mode('like')
```

**Request:** `?q=laravel`

### ExactMatch

[](#exactmatch)

Exact value match on a column. Useful for FK filtering, status filtering, etc.

```
use Dipeshpokhrel\ModelFilter\Filters\ExactMatch;

ExactMatch::make('publisher_id')
ExactMatch::make('book_type_id')
ExactMatch::make('book_status_id')
```

**Request:** `?publisher_id=1`

### RelationshipExists

[](#relationshipexists)

Filter by related model existence via `whereHas`. Automatically resolves the related model's table and filters by its `id` column.

```
use Dipeshpokhrel\ModelFilter\Filters\RelationshipExists;

RelationshipExists::make('genre_id')->relation('genres')
RelationshipExists::make('author_id')->relation('authors')
```

When `relation()` is not called, the parameter name is used as the relationship method name.

**Request:** `?genre_id=3`

### NullCheck

[](#nullcheck)

`whereNull` / `whereNotNull` filter.

```
use Dipeshpokhrel\ModelFilter\Filters\NullCheck;

NullCheck::make('deleted_at')
```

#### NullCheck with column override

[](#nullcheck-with-column-override)

```
NullCheck::make('parent')
    ->column('parent_id')
```

#### NullCheck with value mapping

[](#nullcheck-with-value-mapping)

Map request values to null/not-null checks:

```
NullCheck::make('parent')
    ->column('parent_id')
    ->mapping([
        'top' => 'null',
        'sub' => 'not_null',
    ])
```

**Request:** `?parent=top` adds `WHERE parent_id IS NULL`

### Custom

[](#custom)

Arbitrary closure-based filter. Best for enum-style or multi-condition logic. Has no default behavior -- always use via `using()` or constructor callback.

```
use Dipeshpokhrel\ModelFilter\Filters\Custom;

Custom::make('parent', function (Builder $query, mixed $value): void {
    match ($value) {
        'top' => $query->whereNull('parent_id'),
        'sub' => $query->whereNotNull('parent_id'),
        default => null,
    };
})
```

**Request:** `?parent=top`

Helper Shortcuts
----------------

[](#helper-shortcuts)

The `HasQueryFilters` trait provides shorthand methods so you don't need to import filter classes:

```
$this->textSearch('q', ['title', 'isbn'])        // TextSearch::make('q')->columns([...])
$this->exactMatch('publisher_id')                 // ExactMatch::make('publisher_id')
$this->relationshipExists('genre_id')             // RelationshipExists::make('genre_id')
$this->nullCheck('parent_id')                     // NullCheck::make('parent_id')
$this->custom('parent', fn ($q, $v) => ...)       // Custom::make('parent', fn)
```

Overriding Filter Behavior
--------------------------

[](#overriding-filter-behavior)

Every filter supports two ways to override its default behavior:

### Via `make()` -- pass a callback as the second argument

[](#via-make----pass-a-callback-as-the-second-argument)

```
TextSearch::make('q', function (Builder $query, mixed $value): void {
    $query->where('title', $value)
          ->orWhereHas('tags', fn ($t) => $t->where('name', $value));
})

ExactMatch::make('publisher_id', function (Builder $query, mixed $value): void {
    $query->where('publisher_id', '>', $value);
})
```

### Via `using()` -- fluent override after construction

[](#via-using----fluent-override-after-construction)

```
TextSearch::make('q')->using(function (Builder $query, mixed $value): void {
    $query->where('title', $value)
          ->orWhereHas('tags', fn ($t) => $t->where('name', $value));
})
```

Both approaches are equivalent. `using()` is defined on the base `Filter` class so every filter inherits it.

Customization via Closure
-------------------------

[](#customization-via-closure)

### Filter customization

[](#filter-customization)

Add, remove, or modify filters before they are applied:

```
Post::applyFilter($request, function (array &$filters, Request $request) {
    $filters[] = ExactMatch::make('category_id');
});
```

### Sort customization

[](#sort-customization)

Pass a closure to `applySort()` for complex sort logic (joins, raw expressions, etc.):

```
Post::applySort($request, function (Builder $query, string $sortBy, string $sortDir) {
    match ($sortBy) {
        'author_name' => $query->join('users', 'posts.author_id', '=', 'users.id')
            ->orderBy('users.name', $sortDir)
            ->select('posts.*'),
        default => $query->orderBy($sortBy, $sortDir),
    };
});
```

When a resolver is provided:

- It receives the **raw** `sort_by` value from the request (not validated against `allowedSorts()`)
- `sort_dir` is still resolved to `asc`/`desc`
- The closure takes full control -- no whitelist check, no fallback to `defaultSort()`

Sorting
-------

[](#sorting)

### Simple sorting

[](#simple-sorting)

Defined on the model via `allowedSorts()` and `defaultSort()`:

```
public function allowedSorts(): array
{
    return ['title', 'created_at', 'views_count'];
}

public function defaultSort(): string
{
    return 'title';
}
```

**Request params:** `?sort_by=title&sort_dir=desc`

- `sort_by` must be in `allowedSorts()` or falls back to `defaultSort()`
- `sort_dir` is `asc` (default) or `desc`

Filter API Reference
--------------------

[](#filter-api-reference)

### Base `Filter` class

[](#base-filter-class)

All filters extend `Dipeshpokhrel\ModelFilter\Filter` which provides:

MethodDescription`make(string $parameter, ?Closure $handler = null)`Create a new filter instance`using(Closure $handler)`Override the default handler with a closure`default(mixed $default)`Set a default value when the parameter is missing`required()`Mark the filter as required`getParameter(): string`Get the request parameter name`getDefault(): mixed`Get the default value`isRequired(): bool`Check if the filter is required`__invoke(Builder $query, mixed $value): void`Apply the filter to a query### `HasQueryFilters` trait

[](#hasqueryfilters-trait)

MethodDescription`filters(): array`Define filters for the model. Default: text search on string `$fillable` columns`allowedSorts(): array`Whitelist of sortable columns. Default: `['id']``defaultSort(): string`Fallback sort column. Default: `'id'``scopeApplyFilter(Builder, Request, ?Closure): Builder`Apply all filters from the request`scopeApplySort(Builder, Request, ?Closure): Builder`Apply sorting from the requestConfiguration
-------------

[](#configuration)

Publish the config file:

```
php artisan vendor:publish --tag=model-filter-config
```

```
// config/model-filter.php
return [
    'text_search_mode' => null,        // 'ilike', 'like', or null for auto-detect
    'default_sort' => 'id',            // fallback sort column
    'default_sort_direction' => 'asc',  // default sort direction
];
```

Combining Multiple Filters
--------------------------

[](#combining-multiple-filters)

All filters are applied sequentially. Pass multiple query params to combine:

```
GET /api/v1/books?q=laravel&publisher_id=1&genre_id=3&sort_by=title&per_page=10

```

Adding Filters to a New Model
-----------------------------

[](#adding-filters-to-a-new-model)

1. Add `use HasQueryFilters;` to the model
2. Implement `filters()` returning an array of filter instances
3. Override `allowedSorts()` and `defaultSort()` as needed
4. In the controller, call `->applyFilter($request)->applySort($request)->paginate()`

```
use Dipeshpokhrel\ModelFilter\HasQueryFilters;
use Illuminate\Database\Eloquent\Model;

class Publisher extends Model
{
    use HasQueryFilters;

    public function filters(): array
    {
        return [
            $this->textSearch('q', ['title']),
        ];
    }

    public function allowedSorts(): array
    {
        return ['title', 'created_at'];
    }

    public function defaultSort(): string
    {
        return 'title';
    }
}
```

**Controller:**

```
public function index(Request $request)
{
    return PublisherResource::collection(
        Publisher::applyFilter($request)
            ->applySort($request)
            ->paginate($request->input('per_page', 15)),
    );
}
```

Creating Custom Filters
-----------------------

[](#creating-custom-filters)

Extend the base `Filter` class and implement `handle()`:

```
