A simple trait for Laravel Eloquent models that allows you to easily filter your queries.
firevel/filterable is a Laravel package for a simple trait for laravel eloquent models that allows you to easily filter your queries..
It currently has 0 GitHub stars and 6.138 downloads on Packagist (latest version 0.0.16).
Install it with composer require firevel/filterable.
Discover more Laravel packages by firevel
or browse all Laravel packages to compare alternatives.
Last updated
A lightweight trait for Laravel Eloquent models that makes it easy to build dynamic, type‐safe filters on your queries. Instead of hard‐coding dozens of scopes or query clauses, simply declare which fields are “filterable” and let the trait handle operators, casting, and relationship logic for you.
Install via Composer:
composer require firevel/filterable
Once installed, there are no service‐provider registrations or config publishes required. The trait is ready to use.
Filterable trait to your Eloquent model.$filterable array, mapping each filter key to its type.filter([...]) scope on your queries.// In app/Models/User.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Firevel\Filterable\Filterable;
class User extends Model
{
use Filterable;
/**
* Specify which fields (or “virtual” keys) can be filtered,
* along with their data types.
*/
protected $filterable = [
'id' => 'id',
'first_name' => 'string',
'last_name' => 'string',
'email' => 'string',
'created_at' => 'datetime',
];
}
Now you can do:
$users = User::filter([
'first_name' => ['like' => 'Smith'],
'created_at' => ['>' => '2023-01-01'],
])->get();
––
$filterableIn each model that uses the trait, declare a protected $filterable array. The keys are the names (or aliases) you wish to filter on, and the values specify the field’s type. For example:
protected $filterable = [
'id' => 'id',
'first_name' => 'string',
'last_name' => 'string',
'email' => 'string',
'created_at' => 'datetime',
'is_active' => 'boolean',
'meta' => 'json',
'roles' => 'relationship',
];
full_name that searches both first_name and last_name), see the Composite (“Virtual”) Filters section.The trait will only apply filters for keys explicitly declared in $filterable; any others are ignored by default (or throw an exception if you enable column validation).
| Type | Description |
| ------------- | ------------------------------------------------------------------------------------- |
| integer | Integer columns or numeric IDs |
| id | Shorthand for integer when representing a primary/foreign key |
| string | Text columns; used with operators like like, =, <> |
| date | Date‐only filters (YYYY‐MM‐DD). Under the hood, uses whereDate() |
| datetime | Date & time filters (YYYY‐MM‐DD HH:MM:SS). Uses whereDate() if value is 10 chars long |
| boolean | Casts “true”/“false” (case‐insensitive) to boolean |
| json | JSON columns; used with where() or JSON operators |
| array | JSON columns containing arrays; uses whereJsonContains() |
| relationship| Expect a related model or “has” filter on a belongsTo / hasMany style relationship |
By default, the trait allows the following operators for each filter type. To override operators on a field, simply pass an associative array ('[ operator ] => [ value ]').
| Operator | Alias | Meaning | Allowed Types |
| -------- | ----- | ------------------------------------------ | ------------------------------------------------- |
| = | eq | Equal to (default if no operator provided) | integer, id, string, date, datetime, relationship, boolean, json, array |
| <> | ne | Not equal to | integer, id, string |
| > | gt | Greater than | integer, date, datetime, id, relationship|
| >= | gte | Greater than or equal | integer, date, datetime, id, relationship|
| < | lt | Less than | integer, date, datetime, id, relationship|
| <= | lte | Less than or equal | integer, date, datetime, id, relationship|
| like | - | SQL LIKE (for partial string matches) | string |
| in | - | SQL IN (for lists or comma‐separated values); for array type, checks if JSON array contains value(s) | integer, id, string, json, array |
| is | - | IS NULL check (pass 'null' as value) | integer, date, datetime, id, string, boolean, json, array |
| not | - | IS NOT NULL (pass 'null' as value) | integer, date, datetime, id, string, boolean, json, array |
Note: If you supply a plain scalar (e.g.
'foo') instead of['=' => 'foo'], the trait assumes the=operator by default.
To avoid using special characters in URLs, you can use text-based aliases for comparison operators:
gt for > (greater than)gte for >= (greater than or equal)lt for < (less than)lte for <= (less than or equal)ne for <> (not equal)eq for = (equal)These aliases work exactly the same as their symbolic counterparts:
// Using symbolic operators
$users = User::filter([ 'age' => ['>' => 25] ])->get();
// Using alias operators (URL-friendly)
$users = User::filter([ 'age' => ['gt' => 25] ])->get();
// Both produce: SELECT * FROM users WHERE age > 25
By default, the trait will ignore any filters whose key is not in $filterable. If you’d rather throw an exception when an unknown filter is passed, enable column validation:
class User extends Model
{
use Filterable;
protected $validateColumns = true;
protected $filterable = [
'id' => 'id',
'email' => 'string',
'status' => 'string',
];
}
With $validateColumns = true, passing ->filter(['not_a_column' => ['=' => 5]]) will throw:
Exception: Filter column 'not_a_column' is not allowed.
Filter on one attribute by providing a key‐value pair. If you omit the operator, it defaults to =.
// 1) Simple equality (defaults to '=')
$users = User::filter([ 'id' => 5 ])->get();
// → SELECT * FROM users WHERE id = 5;
// 2) Explicit operators
$users = User::filter([ 'created_at' => ['>' => '2024-01-01'] ])->get();
// → SELECT * FROM users WHERE created_at > '2024-01-01';
// 3) LIKE operator for strings
$users = User::filter([ 'email' => ['like' => '%@example.com'] ])->get();
// → SELECT * FROM users WHERE email LIKE '%@example.com';
Combine as many filters as you need; they are joined with AND logic:
$filters = [
'first_name' => ['like' => 'John'],
'created_at' => ['>=' => '2025-01-01'],
'status' => ['=' => 'active'],
];
$users = User::filter($filters)->get();
// → SELECT * FROM users
// WHERE first_name LIKE '%John%'
// AND created_at >= '2025-01-01'
// AND status = 'active';
Sometimes you want a single filter key (e.g. name) that actually applies to multiple columns (like first_name OR last_name). You can achieve this by declaring a “scope”-type entry in $filterable and then adding a local scope method on your model.
first_name OR last_nameDeclare a scope filter key
In User.php:
use Illuminate\Database\Eloquent\Model;
use Firevel\Filterable\Filterable;
class User extends Model
{
use Filterable;
protected $filterable = [
'first_name' => 'string',
'last_name' => 'string',
'email' => 'string',
'created_at' => 'datetime',
// “name” isn’t a real column; mark it as a custom scope
'name' => 'scope',
];
// Add a local scopeName() to combine first_name OR last_name
// The second parameter ($allFilters) provides access to all filters
public function scopeName($query, $value, $allFilters = [])
{
$query->where(function ($q) use ($value) {
$q->where('first_name', 'like', "%{$value}%")
->orWhere('last_name', 'like', "%{$value}%");
});
}
}
Use it in your code exactly like any other filter
// Will invoke scopeName() internally
$users = User::filter([
'name' => ['like' => 'Smith'],
'created_at' => ['>' => '2025-01-01']
])->get();
Under the hood, the trait sees 'name' => 'scope' and calls $query->name('Smith'), which in turn applies:
WHERE (first_name LIKE '%Smith%' OR last_name LIKE '%Smith%')
AND created_at > '2025-01-01'
if ($filterType === 'scope') and executes the corresponding local scope.scopeName() takes full responsibility for how the filter behaves.The trait supports two naming conventions for scope methods and uses Laravel's Str::studly() for conversion:
scopeFilter{Name} — e.g., scopeFilterSearch for filter key searchscope{Name} — e.g., scopeSearch for filter key searchThe prefixed convention is checked first and is recommended to avoid conflicts with reserved Laravel method names like query, where, get, etc.
// Recommended: use scopeFilter prefix to avoid conflicts
protected $filterable = [
'query' => 'scope', // Safe! Won't conflict with $query->query()
];
public function scopeFilterQuery($query, $value, $allFilters = [])
{
// Your custom filter logic
}
// Also supported for backward compatibility
protected $filterable = [
'name' => 'scope',
];
public function scopeName($query, $value, $allFilters = [])
{
// Your custom filter logic
}
Filter names with underscores or dots are converted using StudlyCase:
| Filter Key | Prefixed Method | Simple Method |
|------------|-----------------|---------------|
| search | scopeFilterSearch | scopeSearch |
| user_status | scopeFilterUserStatus | scopeUserStatus |
| user.role | scopeFilterUserRole | scopeUserRole |
Scope filter methods receive two parameters:
$value - The specific value for this filter$allFilters - The complete array of all filters being appliedThis allows you to create conditional logic based on other filters:
protected $filterable = [
'search' => 'scope',
'category' => 'string',
'status' => 'string',
];
public function scopeSearch($query, $value, $allFilters = [])
{
$query->where(function ($q) use ($value, $allFilters) {
$q->where('title', 'like', "%{$value}%")
->orWhere('description', 'like', "%{$value}%");
// Apply different search logic if category filter is present
if (isset($allFilters['category'])) {
$q->orWhere('tags', 'like', "%{$value}%");
}
});
}
If you have a JSON column (e.g. meta), you can:
Filter by exact JSON key‐value:
protected $filterable = [
'meta' => 'json',
// … other fields …
];
// Get users whose JSON “meta->role” equals “admin”
$users = User::filter([ 'meta->role' => ['=' => 'admin'] ])->get();
// → SELECT * FROM users WHERE JSON_EXTRACT(meta, '$.role') = 'admin';
Filter by array contents (for JSON arrays) by using type array:
protected $filterable = [
'tags' => 'array', // assumes tags is a JSON array column
];
// Check if JSON array contains a value using 'in' operator
$users = User::filter([ 'tags' => ['in' => 'premium'] ])->get();
// → SELECT * FROM users WHERE JSON_CONTAINS(tags, '"premium"');
// Check if JSON array contains ANY of multiple values
$users = User::filter([ 'tags' => ['in' => 'premium,vip'] ])->get();
// → SELECT * FROM users WHERE (JSON_CONTAINS(tags, '"premium"') OR JSON_CONTAINS(tags, '"vip"'));
// Exact match on the entire array using '=' operator
$users = User::filter([ 'tags' => ['=' => '["premium","vip"]'] ])->get();
// → SELECT * FROM users WHERE tags = '["premium","vip"]';
If you want to filter on related models (e.g. User hasMany Order), declare the key as relationship in $filterable. Then pass either:
has() checks).// In User.php
protected $filterable = [
'email' => 'string',
'orders' => 'relationship',
];
// In Order.php (no special setup required)
class Order extends Model { /* … */ }
// 1) Just check that a user has at least one order:
$usersWithAnyOrder = User::filter([ 'orders' => ['>' => 0] ])->get();
// → SELECT * FROM users
// WHERE ( SELECT COUNT(*) FROM orders WHERE orders.user_id = users.id ) > 0;
// 2) Filter by a condition on the order itself:
$filters = [
'orders.status' => ['=' => 'shipped'],
'email' => ['like' => '%@example.com'],
];
$users = User::filter($filters)->get();
// → SELECT * FROM users
// WHERE EXISTS (
// SELECT 1 FROM orders
// WHERE orders.user_id = users.id
// AND status = 'shipped'
// )
// AND email LIKE '%@example.com';
Tip: If you need a more complex subquery on the relationship, you can chain
useRelationshipQuery()before callingfilter().// Define a custom where clause for the related model $relatedWhere = function ($query) { $query->where('price', '>', 100); }; User::useRelationshipQuery($relatedWhere) ->filter([ 'orders' => ['in' => [1,2,3]] ]) ->get();
Boolean
protected $filterable = [
'is_active' => 'boolean',
];
// Accepts true/false, "1"/"0", "true"/"false" (case insensitive)
$activeUsers = User::filter(['is_active' => ['=' => 'true']])->get();
$inactiveUsers = User::filter(['is_active' => ['=' => '0']])->get();
IS NULL / IS NOT NULL
For any type (integer, string, date, etc.), you can check nulls via is or not with the literal 'null':
// Users with no email
$usersNoEmail = User::filter([ 'email' => ['is' => 'null'] ])->get();
// Users where deleted_at IS NOT NULL (soft‐deleted)
$trashed = User::filter([ 'deleted_at' => ['not' => 'null'] ])->get();
Below are a few real‐world scenarios illustrating how you might combine filters.
// 1) Find all “admin” users created in the last 30 days,
// whose email domain is “example.com” and have placed at least one “shipped” order.
$filters = [
'role' => ['=' => 'admin'],
'created_at' => ['>=' => now()->subDays(30)->toDateString()],
'email' => ['like' => '%@example.com'],
'orders.status' => ['=' => 'shipped'],
];
$admins = User::filter($filters)
->orderBy('created_at', 'desc')
->paginate(15);
// 2) Search by “full name” (composite filter: first_name OR last_name),
// and also filter by a JSON metadata key:
$filters = [
'name' => ['like' => 'Doe'], // see “Composite Filters”
'meta->department'=> ['=' => 'engineering'], // JSON column
'status' => ['=' => 'active'],
];
$users = User::filter($filters)->get();
// 3) Get all products whose "tags" JSON array includes "sale" or "new":
$filters = [
'tags' => ['in' => 'sale,new'], // checks if array contains any of these values
];
$productsOnSaleOrNew = Product::filter($filters)->get();
$filterable up to date: Every column or relationship you wish to filter on must appear in the array.protected $validateColumns = true;
This prevents typos or malicious filters from silently being ignored.LIKE '%foo%' is flexible but slow on large tables. Whenever possible, use LIKE 'foo%' or full‐text search.→paginate() or →simplePaginate() to avoid memory issues.“Filter column ‘xyz’ is not allowed.”
You enabled protected $validateColumns = true and passed a key not in $filterable. Either add it to the array or disable validation.
Operator ‘in’ is not allowed for type ‘integer’
Check your $filterable type for that key. The in operator only works on integer, id, string, or json—not on date/datetime out of the box.
Composite filter not working
If you declared a key as 'scope' in $filterable (for example, 'name' => 'scope'), make sure you have a corresponding scopeName() method on the model. If the trait can’t find scopeName, it will skip your filter.
Slow queries on large tables
%…% wildcards (leading %) on very large text columns—those can’t use indexes.With this simple trait, you can keep your controllers and repositories neat, DRY, and expressive—no more copy/pasting dozens of if ($request->has('…')) { … } checks. Happy filtering!