PHPackages                             anhtt/laravel-filter-builder - 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. [Search &amp; Filtering](/categories/search)
4. /
5. anhtt/laravel-filter-builder

ActiveLibrary[Search &amp; Filtering](/categories/search)

anhtt/laravel-filter-builder
============================

Easily build Eloquent queries from API requests

03PHP

Since Jun 9Pushed 3w ago1 watchersCompare

[ Source](https://github.com/bearislove/FilterBuilder)[ Packagist](https://packagist.org/packages/anhtt/laravel-filter-builder)[ RSS](/packages/anhtt-laravel-filter-builder/feed)WikiDiscussions main Synced today

READMEChangelogDependenciesVersions (1)Used By (0)

laravel-filter-builder
======================

[](#laravel-filter-builder)

Easily build Eloquent queries from API request parameters — filters, sorts, joins, eager loads, and more — with zero boilerplate.

---

Table of contents
-----------------

[](#table-of-contents)

- [Installation](#installation)
- [Quick start](#quick-start)
- [FilterConfig — ad-hoc configuration](#filterconfig--ad-hoc-configuration)
- [FilterField — avoid repeating column names](#filterfield--avoid-repeating-column-names)
- [FilterForm — class-based configuration](#filterform--class-based-configuration)
- [Filter formulas reference](#filter-formulas-reference)
- [Sorts](#sorts)
- [Joins](#joins)
- [Select columns](#select-columns)
- [Eager loading (with)](#eager-loading-with)
- [Custom formulas](#custom-formulas)
- [Request keys](#request-keys)
- [Array input keys](#array-input-keys)
- [Swapping handler classes](#swapping-handler-classes)
- [Strict mode](#strict-mode)
- [Publishing &amp; customising the config file](#publishing--customising-the-config-file)
- [Full real-world example](#full-real-world-example)

---

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

[](#installation)

```
composer require anhtt/laravel-filter-builder
```

The service provider is auto-discovered. No manual registration needed.

---

Quick start
-----------

[](#quick-start)

### 1. Add the trait to your model

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

```
// app/Models/User.php
use AnhTT\FilterBuilder\Filterable;

class User extends Authenticatable
{
    use Filterable;
}
```

### 2. Build and apply a filter

[](#2-build-and-apply-a-filter)

```
// app/Http/Controllers/UserController.php
use AnhTT\FilterBuilder\FilterConfig;

public function index()
{
    $filterConfig = FilterConfig::make(
        fields: [
            'name'  => FilterField::make('users.name')->filter('cn')->sortable(),
            'email' => FilterField::make('users.email')->filter('eq')->sortable(),
        ],
        defaultSort: 'id:desc',
    );

    // Request and sort key are resolved automatically from config
    return User::filterBuilder($filterConfig)->paginate();
}
```

**Request:** `GET /users?name=john&sort=name:asc`

---

FilterConfig — ad-hoc configuration
-----------------------------------

[](#filterconfig--ad-hoc-configuration)

Use `FilterConfig` directly when the filter logic is simple or one-off.

```
use AnhTT\FilterBuilder\FilterConfig;

$filterConfig = FilterConfig::make(
    filters: [
        // column:formula
        'id'           => 'users.id:eq',
        'name'         => 'users.name:cn',
        'email'        => 'users.email:eq',
        'status'       => 'users.status:in',
        'age_from'     => 'users.age:gte',
        'age_to'       => 'users.age:lte',
        'created_from' => 'users.created_at:gte',

        // OR-group: matches name OR email
        'keyword' => [
            'users.name:cn',
            'users.email:cn',
        ],

        // Custom callable for complex logic
        'active_premium' => function ($query, $value) {
            $query->where('users.status', 'active')
                  ->where('users.plan', 'premium');
        },
    ],
    sorts: [
        'id'         => 'users.id',
        'name'       => 'users.name',
        'created_at' => 'users.created_at',
    ],
    joins: [
        // Simple format (uses default_join_type from config)
        'orders' => ['orders', 'orders.user_id', '=', 'users.id'],

        // Extended format — specify join type explicitly
        'profiles' => [
            'type' => 'leftJoin',
            'args' => ['profiles', 'profiles.user_id', '=', 'users.id'],
        ],

        // Join with a closure (complex ON condition)
        'roles' => [
            'type' => 'leftJoin',
            'args' => ['roles', function ($join) {
                $join->on('roles.id', '=', 'users.role_id')
                     ->where('roles.active', 1);
            }],
        ],
    ],
    joinPriorities: [
        // 'order_items' requires 'orders' to be joined first
        'order_items' => ['orders'],
    ],
    defaultSort: 'created_at:desc',
);
```

Setters are still available for chaining after creation:

```
$filterConfig = FilterConfig::make(filters: [...])
    ->setSelects(['users.id', 'users.name'])
    ->setWith(['profile']);
```

---

FilterField — avoid repeating column names
------------------------------------------

[](#filterfield--avoid-repeating-column-names)

When the same key appears in both `filters` and `sorts`, the column is written twice. Use `FilterField` to declare the column once and attach both a filter formula and sort in a single expression.

```
use AnhTT\FilterBuilder\FilterField;

FilterConfig::make(
    fields: [
        // filter + sort on the same column
        'name'       => FilterField::make('users.name')->filter('cn')->sortable(),
        'email'      => FilterField::make('users.email')->filter('eq')->sortable(),
        'created_at' => FilterField::make('users.created_at')->filter('date')->sortable(),

        // filter only — not sortable
        'status'     => FilterField::make('users.status')->filter('in'),

        // sort only — no filter
        'rank'       => FilterField::make('users.score')->sortable(),

        // sort on a different column than the filter
        'role'       => FilterField::make('roles.name')->filter('eq')->sortable('roles.sort_order'),

        // OR-group: WHERE (name LIKE ? OR email LIKE ?)
        'search'     => FilterField::any('users.name:cn', 'users.email:cn'),
    ],
    joins: [
        'roles' => ['type' => 'leftJoin', 'args' => ['roles', 'roles.id', '=', 'users.role_id']],
    ],
    defaultSort: 'created_at:desc',
)
```

`fields` and `filters`/`sorts` can be mixed freely — `fields` entries are expanded first, then explicit `filters`/`sorts` entries are merged on top (explicit wins on duplicate keys).

```
FilterConfig::make(
    // Shared fields
    fields: [
        'name' => FilterField::make('users.name')->filter('cn')->sortable(),
    ],
    // Add a filter that doesn't need a sort
    filters: [
        'active_premium' => function ($query, $value) {
            $query->where('status', 'active')->where('plan', 'premium');
        },
    ],
)
```

---

FilterForm — class-based configuration
--------------------------------------

[](#filterform--class-based-configuration)

Extend `FilterForm` for reusable, testable filter definitions. Override only the methods you need.

```
// app/Filters/UserFilterForm.php
namespace App\Filters;

use AnhTT\FilterBuilder\FilterForm;

class UserFilterForm extends FilterForm
{
    protected function filters(): array
    {
        return [
            'id'           => 'users.id:eq',
            'name'         => 'users.name:cn',
            'email'        => 'users.email:eq',
            'status'       => 'users.status:in',
            'role'         => 'roles.name:eq',

            // OR-group across columns
            'search' => [
                'users.name:cn',
                'users.email:cn',
            ],

            // Custom logic
            'verified' => function ($query, $value) {
                if ($value) {
                    $query->whereNotNull('users.email_verified_at');
                } else {
                    $query->whereNull('users.email_verified_at');
                }
            },
        ];
    }

    protected function sorts(): array
    {
        return [
            'id'         => 'users.id',
            'name'       => 'users.name',
            'created_at' => 'users.created_at',
        ];
    }

    protected function joins(): array
    {
        return [
            'roles' => [
                'type' => 'leftJoin',
                'args' => ['roles', 'roles.id', '=', 'users.role_id'],
            ],
        ];
    }

    protected function joinPriorities(): array
    {
        return [];
    }

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

**Usage in controller:**

```
use App\Filters\UserFilterForm;

public function index(Request $request)
{
    $filterConfig = new UserFilterForm();

    return User::filterBuilder(
        $request->all(),
        $filterConfig,
        $request->input('sort', '')
    )->paginate();
}
```

---

Filter formulas reference
-------------------------

[](#filter-formulas-reference)

Formulas are specified as `column:formula` in the filters array.

### Comparison

[](#comparison)

FormulaSQL equivalentExample value`eq``WHERE col = ?``"active"``ne``WHERE col != ?``"banned"``gt``WHERE col > ?``18``gte``WHERE col >= ?``18``lt``WHERE col < ?``100``lte``WHERE col = from AND col = from AND col setFilters([
    // Exact match / not equal
    'status'       => 'users.status:eq',
    'blocked'      => 'users.status:ne',

    // Contains / NOT contains
    'name'         => 'users.name:cn',
    'name_exclude' => 'users.name:ncn',

    // Numeric range: GET /users?age[from]=18&age[to]=30
    'age'          => 'users.age:bw',

    // Date range: GET /users?created[from]=2024-01-01&created[to]=2024-12-31
    'created'      => 'users.created_at:dbw',

    // Exact date (ignores time): GET /users?birthday=1990-05-20
    'birthday'     => 'users.birth_date:date',

    // Date parts: GET /users?year=2024&month=6
    'year'         => 'users.created_at:year',
    'month'        => 'users.created_at:month',

    // Filter by list: GET /users?role_ids[]=1&role_ids[]=2
    'role_ids'     => 'users.role_id:in',

    // Null check: GET /users?deleted=1
    'deleted'      => 'users.deleted_at:not_null',
    'active'       => 'users.deleted_at:null',

    // JSON column: GET /products?tag=electronics
    'tag'          => 'products.tags:json',

    // Column-to-column: rows where discount < price
    // GET /orders?check_discount=orders.price
    'check_discount' => 'orders.discount:col',
])
```

### OR-group (matches any of the columns)

[](#or-group-matches-any-of-the-columns)

```
->setFilters([
    // GET /users?q=john  → WHERE (name LIKE '%john%' OR email LIKE '%john%')
    'q' => [
        'users.name:cn',
        'users.email:cn',
    ],
])
```

### Callable filter (full control)

[](#callable-filter-full-control)

The callable receives `($query, $value, $filterConfig)`.

```
->setFilters([
    'active_premium' => function ($query, $value, $filterConfig) {
        $query->where('users.status', 'active')
              ->where('users.plan', 'premium');
    },
])
```

---

Sorts
-----

[](#sorts)

```
->setSorts([
    'id'         => 'users.id',           // simple column
    'name'       => 'users.name',
    'role_name'  => 'roles.name',         // column from a joined table

    // Callable sort for computed/conditional ordering
    'priority' => function (string $direction, FilterConfig $filterConfig) {
        return function ($query) use ($direction) {
            $query->orderByRaw("FIELD(status, 'active', 'pending', 'inactive') {$direction}");
        };
    },
])
->setDefaultSort('created_at:desc')
```

**Request:** `GET /users?sort=name:asc,created_at:desc`

Multiple sort fields are comma-separated and applied in order. If a field is not in the `sorts` map it is silently ignored. If direction is omitted, falls back to `default_sort_direction` in config (default: `desc`).

---

Joins
-----

[](#joins)

### Simple format

[](#simple-format)

Uses the `default_join_type` from config (default: `leftJoin`).

```
->setJoins([
    'profiles' => ['profiles', 'profiles.user_id', '=', 'users.id'],
    'orders'   => ['orders',   'orders.user_id',   '=', 'users.id'],
])
```

### Extended format — explicit join type

[](#extended-format--explicit-join-type)

```
->setJoins([
    'profiles' => [
        'type' => 'leftJoin',
        'args' => ['profiles', 'profiles.user_id', '=', 'users.id'],
    ],
    'payments' => [
        'type' => 'join',       // INNER JOIN
        'args' => ['payments', 'payments.order_id', '=', 'orders.id'],
    ],
])
```

Accepted `type` values: `join` | `leftJoin` | `rightJoin`.

### Closure ON condition

[](#closure-on-condition)

```
->setJoins([
    'roles' => [
        'type' => 'leftJoin',
        'args' => ['roles', function ($join) {
            $join->on('roles.id', '=', 'users.role_id')
                 ->where('roles.deleted_at', null);
        }],
    ],
])
```

### Join priorities (dependency chain)

[](#join-priorities-dependency-chain)

When joining table A requires table B to already be joined:

```
->setJoins([
    'orders'      => ['orders',      'orders.user_id',    '=', 'users.id'],
    'order_items' => ['order_items', 'order_items.order_id', '=', 'orders.id'],
    'products'    => ['products',    'products.id',       '=', 'order_items.product_id'],
])
->setJoinPriority([
    'order_items' => ['orders'],               // join orders first
    'products'    => ['order_items', 'orders'], // join both first
])
```

Joins are only added when a filter or sort actually references a column from that table — no unnecessary joins.

---

Select columns
--------------

[](#select-columns)

Restrict the columns returned by the query.

```
$filterConfig = (new UserFilterForm())
    ->setSelects([
        'users.id',
        'users.name',
        'users.email',
        'roles.name as role_name',
    ]);
```

When `setSelects` is not called (or called with an empty array), the query defaults to `SELECT *`.

---

Eager loading (with)
--------------------

[](#eager-loading-with)

Load relationships alongside the query result.

```
$filterConfig = (new UserFilterForm())
    ->setWith(['profile', 'roles', 'orders' => function ($query) {
        $query->where('status', 'completed');
    }]);
```

---

Custom formulas
---------------

[](#custom-formulas)

### Per-config (applies to one FilterConfig / FilterForm instance)

[](#per-config-applies-to-one-filterconfig--filterform-instance)

```
$filterConfig = (new FilterConfig())
    ->addFormula('year', function ($query, $column, $value) {
        return $query->whereYear($column, $value);
    })
    ->addFormula('month', function ($query, $column, $value) {
        return $query->whereMonth($column, $value);
    })
    ->setFilters([
        'birth_year'  => 'users.birth_date:year',
        'birth_month' => 'users.birth_date:month',
    ]);
```

### Inside FilterForm

[](#inside-filterform)

```
class UserFilterForm extends FilterForm
{
    public function __construct()
    {
        parent::__construct();

        $this->addFormula('year', fn ($q, $col, $val) => $q->whereYear($col, $val));
    }

    protected function filters(): array
    {
        return [
            'birth_year' => 'users.birth_date:year',
        ];
    }
}
```

### Global formulas via config file

[](#global-formulas-via-config-file)

Add formulas to `config/filter-builder.php` and they are available in every `FilterConfig` instance. These are merged **after** built-ins, so they can also override a built-in formula.

```
// config/filter-builder.php
'formulas' => [
    'year'  => fn ($query, $column, $value) => $query->whereYear($column, $value),
    'month' => fn ($query, $column, $value) => $query->whereMonth($column, $value),
    'day'   => fn ($query, $column, $value) => $query->whereDay($column, $value),

    // Override the built-in 'cn' to use case-insensitive ILIKE on PostgreSQL
    'cn'    => fn ($query, $column, $value) => $query->whereRaw("lower({$column}) like ?", ['%' . strtolower($value) . '%']),
],
```

**Formula priority (highest wins):**

```
built-ins  <  config('filter-builder.formulas')  <  FilterConfig::addFormula()

```

---

Request keys
------------

[](#request-keys)

The `filterBuilder` scope auto-resolves the current request from the container so you never have to pass it manually. Two config keys control which parts of the request it reads.

### `sort_key` — which request parameter carries the sort string

[](#sort_key--which-request-parameter-carries-the-sort-string)

```
// config/filter-builder.php
'sort_key' => 'sort',   // default → GET /users?sort=name:asc
```

Change it if your API uses a different name:

```
'sort_key' => 'order_by',   // → GET /users?order_by=name:asc
```

### `request_data_key` — which request parameter carries filter data

[](#request_data_key--which-request-parameter-carries-filter-data)

```
// config/filter-builder.php
'request_data_key' => null,   // default → reads $request->all()
```

Set a string to namespace all filters under a single key:

```
'request_data_key' => 'filters',
// → GET /users?filters[name]=john&filters[status]=active&sort=name:asc
```

### Calling the scope

[](#calling-the-scope)

```
// Simplest — reads everything from request using config keys
User::filterBuilder($filterConfig)->paginate();

// Pass a specific Request (useful in jobs, commands, tests)
User::filterBuilder($filterConfig, $request)->paginate();

// Pass raw array + explicit sort (full manual control)
User::filterBuilder($filterConfig, $request->all(), 'name:asc')->paginate();

// Raw array without sort
User::filterBuilder($filterConfig, ['name' => 'john'])->paginate();
```

> The `sort` argument passed directly always wins over what is in the request.

---

Array input keys
----------------

[](#array-input-keys)

Some APIs send groups of filters under wrapper keys, e.g.:

```
{
  "keywords": [
    { "name": "status", "value": "active" },
    { "name": "role",   "value": "admin"  }
  ],
  "periods": [
    { "name": "created", "value": { "from": "2024-01-01", "to": "2024-12-31" } }
  ]
}
```

The package unwraps these automatically. Default wrapper keys are `keywords` and `periods`.

**Override globally** in `config/filter-builder.php`:

```
'array_input_keys' => ['keywords', 'periods', 'ranges'],
```

**Override per-config:**

```
$filterConfig->setArrayInputKeys(['keywords', 'periods', 'conditions']);
```

---

Swapping handler classes
------------------------

[](#swapping-handler-classes)

The three internal handlers — `FilterWhere`, `FilterSort`, `FilterJoin` — are resolved through the **Laravel service container**. You can replace any of them globally without touching the package source.

### Option A — via config file

[](#option-a--via-config-file)

```
// config/filter-builder.php
'handlers' => [
    'where' => \App\FilterBuilder\MyFilterWhere::class,
    'sort'  => \AnhTT\FilterBuilder\FilterSort::class,   // keep default
    'join'  => \AnhTT\FilterBuilder\FilterJoin::class,   // keep default
],
```

### Option B — via service container (AppServiceProvider)

[](#option-b--via-service-container-appserviceprovider)

```
use AnhTT\FilterBuilder\FilterWhere;
use App\FilterBuilder\MyFilterWhere;

public function register(): void
{
    $this->app->bind(FilterWhere::class, MyFilterWhere::class);
}
```

Your custom handler only needs to implement the same `getQueries()` signature:

```
// app/FilterBuilder/MyFilterWhere.php
namespace App\FilterBuilder;

use AnhTT\FilterBuilder\FilterConfig;
use AnhTT\FilterBuilder\FilterWhere;

class MyFilterWhere extends FilterWhere
{
    public function getQueries(FilterConfig $filterConfig, array $requestData): array
    {
        // pre-process $requestData, then delegate to parent
        $requestData = $this->sanitize($requestData);
        return parent::getQueries($filterConfig, $requestData);
    }

    private function sanitize(array $data): array
    {
        // strip XSS, trim values, etc.
        return $data;
    }
}
```

---

Strict mode
-----------

[](#strict-mode)

By default, an unknown formula (e.g. a typo like `column:equ`) is silently ignored. Enable strict mode to throw an `InvalidArgumentException` instead — helpful during development.

```
// config/filter-builder.php
'strict_mode' => env('APP_DEBUG', false),
```

With strict mode on:

```
->setFilters([
    'name' => 'users.name:typo',   // throws InvalidArgumentException
])
```

Error message:

```
Unknown filter formula "typo". Register it in config('filter-builder.formulas')
or via FilterConfig::addFormula().

```

---

Publishing &amp; customising the config file
--------------------------------------------

[](#publishing--customising-the-config-file)

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

This creates `config/filter-builder.php` with all available options:

```
return [
    // null = $request->all(); 'filters' = $request->input('filters', [])
    'request_data_key' => null,

    // Request parameter name for the sort string
    'sort_key' => 'sort',

    // Keys in the request treated as arrays of {name, value} filter items
    'array_input_keys' => ['keywords', 'periods'],

    // Default JOIN type: 'join' | 'leftJoin' | 'rightJoin'
    'default_join_type' => 'leftJoin',

    // Fallback sort direction when not specified in the request
    'default_sort_direction' => 'desc',

    // Throw on unknown formula instead of silently skipping
    'strict_mode' => env('APP_DEBUG', false),

    // Global formulas available in all FilterConfig instances
    // (merged after built-ins; per-config formulas have highest priority)
    'formulas' => [],

    // Swappable handler classes (resolved via Laravel container)
    'handlers' => [
        'where' => \AnhTT\FilterBuilder\FilterWhere::class,
        'sort'  => \AnhTT\FilterBuilder\FilterSort::class,
        'join'  => \AnhTT\FilterBuilder\FilterJoin::class,
    ],
];
```

---

Full real-world example
-----------------------

[](#full-real-world-example)

**Scenario:** Product listing with category/brand joins, range filters, keyword search, custom formula, selects, and eager loads.

```
// app/Filters/ProductFilterForm.php
namespace App\Filters;

use AnhTT\FilterBuilder\FilterForm;

class ProductFilterForm extends FilterForm
{
    public function __construct()
    {
        parent::__construct();

        $this->addFormula('year', fn ($q, $col, $val) => $q->whereYear($col, $val));
    }

    protected function filters(): array
    {
        return [
            'name'         => 'products.name:cn',
            'sku'          => 'products.sku:eq',
            'status'       => 'products.status:in',
            'brand_id'     => 'products.brand_id:in',
            'category'     => 'categories.slug:eq',
            'price'        => 'products.price:bw',
            'created_year' => 'products.created_at:year',

            // OR-group: search name OR sku
            'search' => [
                'products.name:cn',
                'products.sku:cn',
            ],

            // Callable: stock filter
            'in_stock' => function ($query, $value) {
                if ($value) {
                    $query->where('products.stock', '>', 0);
                } else {
                    $query->where('products.stock', 0);
                }
            },
        ];
    }

    protected function sorts(): array
    {
        return [
            'name'       => 'products.name',
            'price'      => 'products.price',
            'created_at' => 'products.created_at',
            'category'   => 'categories.name',
        ];
    }

    protected function joins(): array
    {
        return [
            'categories' => [
                'type' => 'leftJoin',
                'args' => ['categories', 'categories.id', '=', 'products.category_id'],
            ],
            'brands' => [
                'type' => 'leftJoin',
                'args' => ['brands', 'brands.id', '=', 'products.brand_id'],
            ],
        ];
    }

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

```
// app/Http/Controllers/ProductController.php
use App\Filters\ProductFilterForm;

public function index(Request $request)
{
    $filterConfig = (new ProductFilterForm())
        ->setSelects([
            'products.id',
            'products.name',
            'products.sku',
            'products.price',
            'products.stock',
            'categories.name as category_name',
            'brands.name as brand_name',
        ])
        ->setWith(['images', 'tags']);

    return Product::filterBuilder(
        $request->all(),
        $filterConfig,
        $request->input('sort', '')
    )->paginate($request->input('per_page', 20));
}
```

**Example request:**

```
GET /products
  ?search=wireless
  &status[]=active
  &status[]=draft
  &price[from]=10
  &price[to]=500
  &sort=price:asc,name:asc
  &per_page=15

```

**Generated SQL (approximate):**

```
SELECT products.id, products.name, products.sku, products.price, products.stock,
       categories.name AS category_name, brands.name AS brand_name
FROM products
LEFT JOIN categories ON categories.id = products.category_id
LEFT JOIN brands ON brands.id = products.brand_id
WHERE (products.name LIKE '%wireless%' OR products.sku LIKE '%wireless%')
  AND products.status IN ('active', 'draft')
  AND products.price >= 10
  AND products.price
