Repository files navigation

Laravel Filterable

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.


Table of Contents

  1. Installation
  2. Quick Start
  3. Configuration
  4. Basic Usage
  5. Advanced Filters
  6. Examples
  7. Tips & Best Practices
  8. Troubleshooting

Installation

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.


Quick Start

  1. Add the Filterable trait to your Eloquent model.
  2. Define a protected $filterable array, mapping each filter key to its type.
  3. Call the filter([...]) scope on your queries.
// In app/Models/User.phpnamespaceApp\Models;
useIlluminate\Database\Eloquent\Model;
useFirevel\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();

––

Configuration

Defining $filterable

In 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',
];
  • If a key corresponds to an actual database column, use its column name.
  • If you want “virtual” filters (e.g. 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).


Allowed Filter Types

TypeDescription
integerInteger columns or numeric IDs
idShorthand for integer when representing a primary/foreign key
floatFloating‐point / decimal columns; same operators as integer
stringText columns; used with operators like like, =, <>
dateDate‐only filters (YYYY‐MM‐DD). Under the hood, uses whereDate()
datetimeDate & time filters (YYYY‐MM‐DD HH:MM:SS). Uses whereDate() if value is 10 chars long
booleanCasts “true”/“false” (case‐insensitive) to boolean
jsonJSON columns; used with where() or JSON operators
arrayJSON columns containing arrays; uses whereJsonContains()
relationshipExpect a related model or “has” filter on a belongsTo / hasMany style relationship
scopeCustom composite filter handled by a local scope method — see Composite (“Virtual”) Filters

Supported Operators

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 ]').

OperatorAliasMeaningAllowed Types
=eqEqual to (default if no operator provided)integer, id, float, string, date, datetime, relationship, boolean, json, array
<>neNot equal tointeger, id, float, string
>gtGreater thaninteger, date, datetime, id, float, relationship
>=gteGreater than or equalinteger, date, datetime, id, float, relationship
<ltLess thaninteger, date, datetime, id, float, relationship
<=lteLess than or equalinteger, date, datetime, id, float, relationship
like-SQL LIKE. The value is passed verbatim — include % wildcards yourself (e.g. '%foo%')string
in-SQL IN (for lists or comma‐separated values); for array type, checks if JSON array contains value(s); for relationship type, matches against the related model's primary keyinteger, id, float, string, json, array, relationship
is-IS NULL check (pass 'null' as value)integer, date, datetime, id, float, string, boolean, json, array
not-IS NOT NULL (pass 'null' as value)integer, date, datetime, id, float, string, boolean, json, array

Note: If you supply a plain scalar (e.g. 'foo') instead of ['=' => 'foo'], the trait assumes the = operator by default.

Operator Aliases

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

Validating Columns

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.

Basic Usage

Filtering by Single Field

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';

Filtering by Multiple Fields

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';

Composite (“Virtual”) Filters

Sometimes you want a single filter key (e.g. name) that actually applies to multiple columns (like first_nameORlast_name). You can achieve this by declaring a “scope”-type entry in $filterable and then adding a local scope method on your model.

Example: “name” → searches first_name OR last_name

  1. Declare a scope filter key
    In User.php:

    useIlluminate\Database\Eloquent\Model;
    useFirevel\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 filterspublicfunctionscopeName($query, $value, $allFilters = [])
    {
    $query->where(function ($q) use ($value) {
    $q->where('first_name', 'like', "%{$value}%")
    ->orWhere('last_name', 'like', "%{$value}%");
    });
    }
    }
  2. 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'

Why use a "scope"-type filter?

  • Zero changes to the trait: the existing code already checks if ($filterType === 'scope') and executes the corresponding local scope.
  • Keeps your trait logic simple: you don't have to override the trait's internal validation or operator parsing—your scopeName() takes full responsibility for how the filter behaves.
  • Reusable & readable: everyone knows that "scopeX" is a local query modifier, and the trait simply defers to it.

Scope Method Naming Convention

The trait supports two naming conventions for scope methods and uses Laravel's Str::studly() for conversion:

  1. Prefixed (recommended): scopeFilter{Name} — e.g., scopeFilterSearch for filter key search
  2. Simple: scope{Name} — e.g., scopeSearch for filter key search

The 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 conflictsprotected$filterable = [
'query' => 'scope', // Safe! Won't conflict with $query->query()
];
publicfunctionscopeFilterQuery($query, $value, $allFilters = [])
{
// Your custom filter logic
}
// Also supported for backward compatibilityprotected$filterable = [
'name' => 'scope',
];
publicfunctionscopeName($query, $value, $allFilters = [])
{
// Your custom filter logic
}

Filter names with underscores are converted using StudlyCase:

Filter KeyPrefixed MethodSimple Method
searchscopeFilterSearchscopeSearch
user_statusscopeFilterUserStatusscopeUserStatus

Note: dots are not converted the same way—Str::studly() only treats - and _ as word separators, so a key like user.role would studly-case to User.role (literal dot kept), which can never match a real PHP method name. Don't use dots in 'scope'-type filter keys; use underscores instead (e.g. user_role).

Accessing Other Filters in Scope Methods

Scope filter methods receive two parameters:

  1. $value - The specific value for this filter
  2. $allFilters - The complete array of all filters being applied

This allows you to create conditional logic based on other filters:

protected$filterable = [
'search' => 'scope',
'category' => 'string',
'status' => 'string',
];
publicfunctionscopeSearch($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 presentif (isset($allFilters['category'])) {
$q->orWhere('tags', 'like', "%{$value}%");
}
});
}

Advanced Filters

Filtering JSON Columns

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"]';

Filtering Relationships

If you want to filter on related models (e.g. User hasMany Order), declare the key as relationship in $filterable. Then pass either:

  1. A scalar/array (for simple has() checks).
  2. A nested filter array to apply conditions on the related model.
// In User.phpprotected$filterable = [
'email' => 'string',
'orders' => 'relationship',
'orders.status' => 'string', // related columns are declared with dot notation
];
// 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';

Dot notation composes with JSON -> paths, so you can filter on a nested value inside a related model's JSON column:

// In User.phpprotected$filterable = [
'orders' => 'relationship',
'orders.details' => 'json',
];
$users = User::filter([ 'orders.details->type' => 'priority' ])->get();
// → SELECT * FROM users// WHERE EXISTS (// SELECT 1 FROM orders// WHERE orders.user_id = users.id// AND json_extract(orders.details, '$.type') = 'priority'// );

Tip: If you need a more complex subquery on the relationship, you can chain useRelationshipQuery() before calling filter().

// 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();

For a relationship-type filter, in matches against the related model's primary key (so [1,2,3] above means "has an order whose id is 1, 2, or 3"), combined with the useRelationshipQuery() closure when one is set.


Boolean & Null Checks

  • 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();

Examples

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();

Tips & Best Practices

  • Keep $filterable up to date: Every column or relationship you wish to filter on must appear in the array.
  • Use strict column validation in production:
    protected$validateColumns = true;
    This prevents typos or malicious filters from silently being ignored.
  • Leverage composite (virtual) filters sparingly: Only create a custom scope if you truly need to combine two or more columns into one semantic filter.
  • Avoid leading wildcards unless necessary:
    • LIKE '%foo%' is flexible but slow on large tables. Whenever possible, use LIKE 'foo%' or full‐text search.
  • Paginate filtered results: Filtering can return large result sets. Always pair with →paginate() or →simplePaginate() to avoid memory issues.
  • Test your JSON and relationship filters thoroughly—wrong syntax or missing indexes can lead to unexpected results or performance hits.

Troubleshooting

  • “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.

  • “Illegal filter name ‘…’.”
    The filter key contained a character that a real column, relationship or JSON path never needs. Filter values are always bound as query parameters, but the key is written into the SQL itself, so keys are restricted to word characters, . (relationships), -> (JSON paths) and [] (array indexes). Quotes, spaces and other punctuation are rejected regardless of $validateColumns. This is a security guard—if you are seeing it for legitimate input, your JSON keys are unusually named; access them by their normal name (meta->tags[0], not meta->'tags').

  • Operator ‘in’ is not allowed for type ‘date’ (or boolean)
    Check your $filterable type for that key. The in operator only works on integer, id, float, string, json, array, and relationship—not on date/datetime/boolean 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 scopeFilterName() (recommended) or scopeName() method on the model. If neither method exists, the trait throws Scope method 'scopeFilterName' or 'scopeName' not found on model. rather than silently skipping the filter.

  • Slow queries on large tables

    • Check if you’re using %…% wildcards (leading %) on very large text columns—those can’t use indexes.
    • Consider adding a full‐text index for complex search scenarios or switch to a dedicated search engine (Scout, Algolia, MeiliSearch).

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!

About

A simple trait for Laravel Eloquent models that allows you to easily filter your queries.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

Laravel Filterable

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.


Table of Contents

  1. Installation
  2. Quick Start
  3. Configuration
  4. Basic Usage
  5. Advanced Filters
  6. Examples
  7. Tips & Best Practices
  8. Troubleshooting

Installation

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.


Quick Start

  1. Add the Filterable trait to your Eloquent model.
  2. Define a protected $filterable array, mapping each filter key to its type.
  3. Call the filter([...]) scope on your queries.
// In app/Models/User.phpnamespaceApp\Models;
useIlluminate\Database\Eloquent\Model;
useFirevel\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();

––

Configuration

Defining $filterable

In 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',
];
  • If a key corresponds to an actual database column, use its column name.
  • If you want “virtual” filters (e.g. 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).


Allowed Filter Types

TypeDescription
integerInteger columns or numeric IDs
idShorthand for integer when representing a primary/foreign key
floatFloating‐point / decimal columns; same operators as integer
stringText columns; used with operators like like, =, <>
dateDate‐only filters (YYYY‐MM‐DD). Under the hood, uses whereDate()
datetimeDate & time filters (YYYY‐MM‐DD HH:MM:SS). Uses whereDate() if value is 10 chars long
booleanCasts “true”/“false” (case‐insensitive) to boolean
jsonJSON columns; used with where() or JSON operators
arrayJSON columns containing arrays; uses whereJsonContains()
relationshipExpect a related model or “has” filter on a belongsTo / hasMany style relationship
scopeCustom composite filter handled by a local scope method — see Composite (“Virtual”) Filters

Supported Operators

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 ]').

OperatorAliasMeaningAllowed Types
=eqEqual to (default if no operator provided)integer, id, float, string, date, datetime, relationship, boolean, json, array
<>neNot equal tointeger, id, float, string
>gtGreater thaninteger, date, datetime, id, float, relationship
>=gteGreater than or equalinteger, date, datetime, id, float, relationship
<ltLess thaninteger, date, datetime, id, float, relationship
<=lteLess than or equalinteger, date, datetime, id, float, relationship
like-SQL LIKE. The value is passed verbatim — include % wildcards yourself (e.g. '%foo%')string
in-SQL IN (for lists or comma‐separated values); for array type, checks if JSON array contains value(s); for relationship type, matches against the related model's primary keyinteger, id, float, string, json, array, relationship
is-IS NULL check (pass 'null' as value)integer, date, datetime, id, float, string, boolean, json, array
not-IS NOT NULL (pass 'null' as value)integer, date, datetime, id, float, string, boolean, json, array

Note: If you supply a plain scalar (e.g. 'foo') instead of ['=' => 'foo'], the trait assumes the = operator by default.

Operator Aliases

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

Validating Columns

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.

Basic Usage

Filtering by Single Field

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';

Filtering by Multiple Fields

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';

Composite (“Virtual”) Filters

Sometimes you want a single filter key (e.g. name) that actually applies to multiple columns (like first_nameORlast_name). You can achieve this by declaring a “scope”-type entry in $filterable and then adding a local scope method on your model.

Example: “name” → searches first_name OR last_name

  1. Declare a scope filter key
    In User.php:

    useIlluminate\Database\Eloquent\Model;
    useFirevel\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 filterspublicfunctionscopeName($query, $value, $allFilters = [])
    {
    $query->where(function ($q) use ($value) {
    $q->where('first_name', 'like', "%{$value}%")
    ->orWhere('last_name', 'like', "%{$value}%");
    });
    }
    }
  2. 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'

Why use a "scope"-type filter?

  • Zero changes to the trait: the existing code already checks if ($filterType === 'scope') and executes the corresponding local scope.
  • Keeps your trait logic simple: you don't have to override the trait's internal validation or operator parsing—your scopeName() takes full responsibility for how the filter behaves.
  • Reusable & readable: everyone knows that "scopeX" is a local query modifier, and the trait simply defers to it.

Scope Method Naming Convention

The trait supports two naming conventions for scope methods and uses Laravel's Str::studly() for conversion:

  1. Prefixed (recommended): scopeFilter{Name} — e.g., scopeFilterSearch for filter key search
  2. Simple: scope{Name} — e.g., scopeSearch for filter key search

The 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 conflictsprotected$filterable = [
'query' => 'scope', // Safe! Won't conflict with $query->query()
];
publicfunctionscopeFilterQuery($query, $value, $allFilters = [])
{
// Your custom filter logic
}
// Also supported for backward compatibilityprotected$filterable = [
'name' => 'scope',
];
publicfunctionscopeName($query, $value, $allFilters = [])
{
// Your custom filter logic
}

Filter names with underscores are converted using StudlyCase:

Filter KeyPrefixed MethodSimple Method
searchscopeFilterSearchscopeSearch
user_statusscopeFilterUserStatusscopeUserStatus

Note: dots are not converted the same way—Str::studly() only treats - and _ as word separators, so a key like user.role would studly-case to User.role (literal dot kept), which can never match a real PHP method name. Don't use dots in 'scope'-type filter keys; use underscores instead (e.g. user_role).

Accessing Other Filters in Scope Methods

Scope filter methods receive two parameters:

  1. $value - The specific value for this filter
  2. $allFilters - The complete array of all filters being applied

This allows you to create conditional logic based on other filters:

protected$filterable = [
'search' => 'scope',
'category' => 'string',
'status' => 'string',
];
publicfunctionscopeSearch($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 presentif (isset($allFilters['category'])) {
$q->orWhere('tags', 'like', "%{$value}%");
}
});
}

Advanced Filters

Filtering JSON Columns

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"]';

Filtering Relationships

If you want to filter on related models (e.g. User hasMany Order), declare the key as relationship in $filterable. Then pass either:

  1. A scalar/array (for simple has() checks).
  2. A nested filter array to apply conditions on the related model.
// In User.phpprotected$filterable = [
'email' => 'string',
'orders' => 'relationship',
'orders.status' => 'string', // related columns are declared with dot notation
];
// 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';

Dot notation composes with JSON -> paths, so you can filter on a nested value inside a related model's JSON column:

// In User.phpprotected$filterable = [
'orders' => 'relationship',
'orders.details' => 'json',
];
$users = User::filter([ 'orders.details->type' => 'priority' ])->get();
// → SELECT * FROM users// WHERE EXISTS (// SELECT 1 FROM orders// WHERE orders.user_id = users.id// AND json_extract(orders.details, '$.type') = 'priority'// );

Tip: If you need a more complex subquery on the relationship, you can chain useRelationshipQuery() before calling filter().

// 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();

For a relationship-type filter, in matches against the related model's primary key (so [1,2,3] above means "has an order whose id is 1, 2, or 3"), combined with the useRelationshipQuery() closure when one is set.


Boolean & Null Checks

  • 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();

Examples

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();

Tips & Best Practices

  • Keep $filterable up to date: Every column or relationship you wish to filter on must appear in the array.
  • Use strict column validation in production:
    protected$validateColumns = true;
    This prevents typos or malicious filters from silently being ignored.
  • Leverage composite (virtual) filters sparingly: Only create a custom scope if you truly need to combine two or more columns into one semantic filter.
  • Avoid leading wildcards unless necessary:
    • LIKE '%foo%' is flexible but slow on large tables. Whenever possible, use LIKE 'foo%' or full‐text search.
  • Paginate filtered results: Filtering can return large result sets. Always pair with →paginate() or →simplePaginate() to avoid memory issues.
  • Test your JSON and relationship filters thoroughly—wrong syntax or missing indexes can lead to unexpected results or performance hits.

Troubleshooting

  • “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.

  • “Illegal filter name ‘…’.”
    The filter key contained a character that a real column, relationship or JSON path never needs. Filter values are always bound as query parameters, but the key is written into the SQL itself, so keys are restricted to word characters, . (relationships), -> (JSON paths) and [] (array indexes). Quotes, spaces and other punctuation are rejected regardless of $validateColumns. This is a security guard—if you are seeing it for legitimate input, your JSON keys are unusually named; access them by their normal name (meta->tags[0], not meta->'tags').

  • Operator ‘in’ is not allowed for type ‘date’ (or boolean)
    Check your $filterable type for that key. The in operator only works on integer, id, float, string, json, array, and relationship—not on date/datetime/boolean 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 scopeFilterName() (recommended) or scopeName() method on the model. If neither method exists, the trait throws Scope method 'scopeFilterName' or 'scopeName' not found on model. rather than silently skipping the filter.

  • Slow queries on large tables

    • Check if you’re using %…% wildcards (leading %) on very large text columns—those can’t use indexes.
    • Consider adding a full‐text index for complex search scenarios or switch to a dedicated search engine (Scout, Algolia, MeiliSearch).

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!

About

A simple trait for Laravel Eloquent models that allows you to easily filter your queries.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Laravel Filterable

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.


Table of Contents

  1. Installation
  2. Quick Start
  3. Configuration
  4. Basic Usage
  5. Advanced Filters
  6. Examples
  7. Tips & Best Practices
  8. Troubleshooting

Installation

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.


Quick Start

  1. Add the Filterable trait to your Eloquent model.
  2. Define a protected $filterable array, mapping each filter key to its type.
  3. Call the filter([...]) scope on your queries.
// In app/Models/User.phpnamespaceApp\Models;
useIlluminate\Database\Eloquent\Model;
useFirevel\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();

––

Configuration

Defining $filterable

In 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',
];
  • If a key corresponds to an actual database column, use its column name.
  • If you want “virtual” filters (e.g. 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).


Allowed Filter Types

TypeDescription
integerInteger columns or numeric IDs
idShorthand for integer when representing a primary/foreign key
floatFloating‐point / decimal columns; same operators as integer
stringText columns; used with operators like like, =, <>
dateDate‐only filters (YYYY‐MM‐DD). Under the hood, uses whereDate()
datetimeDate & time filters (YYYY‐MM‐DD HH:MM:SS). Uses whereDate() if value is 10 chars long
booleanCasts “true”/“false” (case‐insensitive) to boolean
jsonJSON columns; used with where() or JSON operators
arrayJSON columns containing arrays; uses whereJsonContains()
relationshipExpect a related model or “has” filter on a belongsTo / hasMany style relationship
scopeCustom composite filter handled by a local scope method — see Composite (“Virtual”) Filters

Supported Operators

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 ]').

OperatorAliasMeaningAllowed Types
=eqEqual to (default if no operator provided)integer, id, float, string, date, datetime, relationship, boolean, json, array
<>neNot equal tointeger, id, float, string
>gtGreater thaninteger, date, datetime, id, float, relationship
>=gteGreater than or equalinteger, date, datetime, id, float, relationship
<ltLess thaninteger, date, datetime, id, float, relationship
<=lteLess than or equalinteger, date, datetime, id, float, relationship
like-SQL LIKE. The value is passed verbatim — include % wildcards yourself (e.g. '%foo%')string
in-SQL IN (for lists or comma‐separated values); for array type, checks if JSON array contains value(s); for relationship type, matches against the related model's primary keyinteger, id, float, string, json, array, relationship
is-IS NULL check (pass 'null' as value)integer, date, datetime, id, float, string, boolean, json, array
not-IS NOT NULL (pass 'null' as value)integer, date, datetime, id, float, string, boolean, json, array

Note: If you supply a plain scalar (e.g. 'foo') instead of ['=' => 'foo'], the trait assumes the = operator by default.

Operator Aliases

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

Validating Columns

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.

Basic Usage

Filtering by Single Field

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';

Filtering by Multiple Fields

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';

Composite (“Virtual”) Filters

Sometimes you want a single filter key (e.g. name) that actually applies to multiple columns (like first_nameORlast_name). You can achieve this by declaring a “scope”-type entry in $filterable and then adding a local scope method on your model.

Example: “name” → searches first_name OR last_name

  1. Declare a scope filter key
    In User.php:

    useIlluminate\Database\Eloquent\Model;
    useFirevel\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 filterspublicfunctionscopeName($query, $value, $allFilters = [])
    {
    $query->where(function ($q) use ($value) {
    $q->where('first_name', 'like', "%{$value}%")
    ->orWhere('last_name', 'like', "%{$value}%");
    });
    }
    }
  2. 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'

Why use a "scope"-type filter?

  • Zero changes to the trait: the existing code already checks if ($filterType === 'scope') and executes the corresponding local scope.
  • Keeps your trait logic simple: you don't have to override the trait's internal validation or operator parsing—your scopeName() takes full responsibility for how the filter behaves.
  • Reusable & readable: everyone knows that "scopeX" is a local query modifier, and the trait simply defers to it.

Scope Method Naming Convention

The trait supports two naming conventions for scope methods and uses Laravel's Str::studly() for conversion:

  1. Prefixed (recommended): scopeFilter{Name} — e.g., scopeFilterSearch for filter key search
  2. Simple: scope{Name} — e.g., scopeSearch for filter key search

The 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 conflictsprotected$filterable = [
'query' => 'scope', // Safe! Won't conflict with $query->query()
];
publicfunctionscopeFilterQuery($query, $value, $allFilters = [])
{
// Your custom filter logic
}
// Also supported for backward compatibilityprotected$filterable = [
'name' => 'scope',
];
publicfunctionscopeName($query, $value, $allFilters = [])
{
// Your custom filter logic
}

Filter names with underscores are converted using StudlyCase:

Filter KeyPrefixed MethodSimple Method
searchscopeFilterSearchscopeSearch
user_statusscopeFilterUserStatusscopeUserStatus

Note: dots are not converted the same way—Str::studly() only treats - and _ as word separators, so a key like user.role would studly-case to User.role (literal dot kept), which can never match a real PHP method name. Don't use dots in 'scope'-type filter keys; use underscores instead (e.g. user_role).

Accessing Other Filters in Scope Methods

Scope filter methods receive two parameters:

  1. $value - The specific value for this filter
  2. $allFilters - The complete array of all filters being applied

This allows you to create conditional logic based on other filters:

protected$filterable = [
'search' => 'scope',
'category' => 'string',
'status' => 'string',
];
publicfunctionscopeSearch($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 presentif (isset($allFilters['category'])) {
$q->orWhere('tags', 'like', "%{$value}%");
}
});
}

Advanced Filters

Filtering JSON Columns

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"]';

Filtering Relationships

If you want to filter on related models (e.g. User hasMany Order), declare the key as relationship in $filterable. Then pass either:

  1. A scalar/array (for simple has() checks).
  2. A nested filter array to apply conditions on the related model.
// In User.phpprotected$filterable = [
'email' => 'string',
'orders' => 'relationship',
'orders.status' => 'string', // related columns are declared with dot notation
];
// 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';

Dot notation composes with JSON -> paths, so you can filter on a nested value inside a related model's JSON column:

// In User.phpprotected$filterable = [
'orders' => 'relationship',
'orders.details' => 'json',
];
$users = User::filter([ 'orders.details->type' => 'priority' ])->get();
// → SELECT * FROM users// WHERE EXISTS (// SELECT 1 FROM orders// WHERE orders.user_id = users.id// AND json_extract(orders.details, '$.type') = 'priority'// );

Tip: If you need a more complex subquery on the relationship, you can chain useRelationshipQuery() before calling filter().

// 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();

For a relationship-type filter, in matches against the related model's primary key (so [1,2,3] above means "has an order whose id is 1, 2, or 3"), combined with the useRelationshipQuery() closure when one is set.


Boolean & Null Checks

  • 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();

Examples

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();

Tips & Best Practices

  • Keep $filterable up to date: Every column or relationship you wish to filter on must appear in the array.
  • Use strict column validation in production:
    protected$validateColumns = true;
    This prevents typos or malicious filters from silently being ignored.
  • Leverage composite (virtual) filters sparingly: Only create a custom scope if you truly need to combine two or more columns into one semantic filter.
  • Avoid leading wildcards unless necessary:
    • LIKE '%foo%' is flexible but slow on large tables. Whenever possible, use LIKE 'foo%' or full‐text search.
  • Paginate filtered results: Filtering can return large result sets. Always pair with →paginate() or →simplePaginate() to avoid memory issues.
  • Test your JSON and relationship filters thoroughly—wrong syntax or missing indexes can lead to unexpected results or performance hits.

Troubleshooting

  • “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.

  • “Illegal filter name ‘…’.”
    The filter key contained a character that a real column, relationship or JSON path never needs. Filter values are always bound as query parameters, but the key is written into the SQL itself, so keys are restricted to word characters, . (relationships), -> (JSON paths) and [] (array indexes). Quotes, spaces and other punctuation are rejected regardless of $validateColumns. This is a security guard—if you are seeing it for legitimate input, your JSON keys are unusually named; access them by their normal name (meta->tags[0], not meta->'tags').

  • Operator ‘in’ is not allowed for type ‘date’ (or boolean)
    Check your $filterable type for that key. The in operator only works on integer, id, float, string, json, array, and relationship—not on date/datetime/boolean 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 scopeFilterName() (recommended) or scopeName() method on the model. If neither method exists, the trait throws Scope method 'scopeFilterName' or 'scopeName' not found on model. rather than silently skipping the filter.

  • Slow queries on large tables

    • Check if you’re using %…% wildcards (leading %) on very large text columns—those can’t use indexes.
    • Consider adding a full‐text index for complex search scenarios or switch to a dedicated search engine (Scout, Algolia, MeiliSearch).

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!

About

A simple trait for Laravel Eloquent models that allows you to easily filter your queries.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Laravel Filterable

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.


Table of Contents

  1. Installation
  2. Quick Start
  3. Configuration
  4. Basic Usage
  5. Advanced Filters
  6. Examples
  7. Tips & Best Practices
  8. Troubleshooting

Installation

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.


Quick Start

  1. Add the Filterable trait to your Eloquent model.
  2. Define a protected $filterable array, mapping each filter key to its type.
  3. Call the filter([...]) scope on your queries.
// In app/Models/User.phpnamespaceApp\Models;
useIlluminate\Database\Eloquent\Model;
useFirevel\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();

––

Configuration

Defining $filterable

In 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',
];
  • If a key corresponds to an actual database column, use its column name.
  • If you want “virtual” filters (e.g. 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).


Allowed Filter Types

TypeDescription
integerInteger columns or numeric IDs
idShorthand for integer when representing a primary/foreign key
floatFloating‐point / decimal columns; same operators as integer
stringText columns; used with operators like like, =, <>
dateDate‐only filters (YYYY‐MM‐DD). Under the hood, uses whereDate()
datetimeDate & time filters (YYYY‐MM‐DD HH:MM:SS). Uses whereDate() if value is 10 chars long
booleanCasts “true”/“false” (case‐insensitive) to boolean
jsonJSON columns; used with where() or JSON operators
arrayJSON columns containing arrays; uses whereJsonContains()
relationshipExpect a related model or “has” filter on a belongsTo / hasMany style relationship
scopeCustom composite filter handled by a local scope method — see Composite (“Virtual”) Filters

Supported Operators

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 ]').

OperatorAliasMeaningAllowed Types
=eqEqual to (default if no operator provided)integer, id, float, string, date, datetime, relationship, boolean, json, array
<>neNot equal tointeger, id, float, string
>gtGreater thaninteger, date, datetime, id, float, relationship
>=gteGreater than or equalinteger, date, datetime, id, float, relationship
<ltLess thaninteger, date, datetime, id, float, relationship
<=lteLess than or equalinteger, date, datetime, id, float, relationship
like-SQL LIKE. The value is passed verbatim — include % wildcards yourself (e.g. '%foo%')string
in-SQL IN (for lists or comma‐separated values); for array type, checks if JSON array contains value(s); for relationship type, matches against the related model's primary keyinteger, id, float, string, json, array, relationship
is-IS NULL check (pass 'null' as value)integer, date, datetime, id, float, string, boolean, json, array
not-IS NOT NULL (pass 'null' as value)integer, date, datetime, id, float, string, boolean, json, array

Note: If you supply a plain scalar (e.g. 'foo') instead of ['=' => 'foo'], the trait assumes the = operator by default.

Operator Aliases

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

Validating Columns

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.

Basic Usage

Filtering by Single Field

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';

Filtering by Multiple Fields

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';

Composite (“Virtual”) Filters

Sometimes you want a single filter key (e.g. name) that actually applies to multiple columns (like first_nameORlast_name). You can achieve this by declaring a “scope”-type entry in $filterable and then adding a local scope method on your model.

Example: “name” → searches first_name OR last_name

  1. Declare a scope filter key
    In User.php:

    useIlluminate\Database\Eloquent\Model;
    useFirevel\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 filterspublicfunctionscopeName($query, $value, $allFilters = [])
    {
    $query->where(function ($q) use ($value) {
    $q->where('first_name', 'like', "%{$value}%")
    ->orWhere('last_name', 'like', "%{$value}%");
    });
    }
    }
  2. 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'

Why use a "scope"-type filter?

  • Zero changes to the trait: the existing code already checks if ($filterType === 'scope') and executes the corresponding local scope.
  • Keeps your trait logic simple: you don't have to override the trait's internal validation or operator parsing—your scopeName() takes full responsibility for how the filter behaves.
  • Reusable & readable: everyone knows that "scopeX" is a local query modifier, and the trait simply defers to it.

Scope Method Naming Convention

The trait supports two naming conventions for scope methods and uses Laravel's Str::studly() for conversion:

  1. Prefixed (recommended): scopeFilter{Name} — e.g., scopeFilterSearch for filter key search
  2. Simple: scope{Name} — e.g., scopeSearch for filter key search

The 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 conflictsprotected$filterable = [
'query' => 'scope', // Safe! Won't conflict with $query->query()
];
publicfunctionscopeFilterQuery($query, $value, $allFilters = [])
{
// Your custom filter logic
}
// Also supported for backward compatibilityprotected$filterable = [
'name' => 'scope',
];
publicfunctionscopeName($query, $value, $allFilters = [])
{
// Your custom filter logic
}

Filter names with underscores are converted using StudlyCase:

Filter KeyPrefixed MethodSimple Method
searchscopeFilterSearchscopeSearch
user_statusscopeFilterUserStatusscopeUserStatus

Note: dots are not converted the same way—Str::studly() only treats - and _ as word separators, so a key like user.role would studly-case to User.role (literal dot kept), which can never match a real PHP method name. Don't use dots in 'scope'-type filter keys; use underscores instead (e.g. user_role).

Accessing Other Filters in Scope Methods

Scope filter methods receive two parameters:

  1. $value - The specific value for this filter
  2. $allFilters - The complete array of all filters being applied

This allows you to create conditional logic based on other filters:

protected$filterable = [
'search' => 'scope',
'category' => 'string',
'status' => 'string',
];
publicfunctionscopeSearch($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 presentif (isset($allFilters['category'])) {
$q->orWhere('tags', 'like', "%{$value}%");
}
});
}

Advanced Filters

Filtering JSON Columns

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"]';

Filtering Relationships

If you want to filter on related models (e.g. User hasMany Order), declare the key as relationship in $filterable. Then pass either:

  1. A scalar/array (for simple has() checks).
  2. A nested filter array to apply conditions on the related model.
// In User.phpprotected$filterable = [
'email' => 'string',
'orders' => 'relationship',
'orders.status' => 'string', // related columns are declared with dot notation
];
// 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';

Dot notation composes with JSON -> paths, so you can filter on a nested value inside a related model's JSON column:

// In User.phpprotected$filterable = [
'orders' => 'relationship',
'orders.details' => 'json',
];
$users = User::filter([ 'orders.details->type' => 'priority' ])->get();
// → SELECT * FROM users// WHERE EXISTS (// SELECT 1 FROM orders// WHERE orders.user_id = users.id// AND json_extract(orders.details, '$.type') = 'priority'// );

Tip: If you need a more complex subquery on the relationship, you can chain useRelationshipQuery() before calling filter().

// 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();

For a relationship-type filter, in matches against the related model's primary key (so [1,2,3] above means "has an order whose id is 1, 2, or 3"), combined with the useRelationshipQuery() closure when one is set.


Boolean & Null Checks

  • 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();

Examples

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();

Tips & Best Practices

  • Keep $filterable up to date: Every column or relationship you wish to filter on must appear in the array.
  • Use strict column validation in production:
    protected$validateColumns = true;
    This prevents typos or malicious filters from silently being ignored.
  • Leverage composite (virtual) filters sparingly: Only create a custom scope if you truly need to combine two or more columns into one semantic filter.
  • Avoid leading wildcards unless necessary:
    • LIKE '%foo%' is flexible but slow on large tables. Whenever possible, use LIKE 'foo%' or full‐text search.
  • Paginate filtered results: Filtering can return large result sets. Always pair with →paginate() or →simplePaginate() to avoid memory issues.
  • Test your JSON and relationship filters thoroughly—wrong syntax or missing indexes can lead to unexpected results or performance hits.

Troubleshooting

  • “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.

  • “Illegal filter name ‘…’.”
    The filter key contained a character that a real column, relationship or JSON path never needs. Filter values are always bound as query parameters, but the key is written into the SQL itself, so keys are restricted to word characters, . (relationships), -> (JSON paths) and [] (array indexes). Quotes, spaces and other punctuation are rejected regardless of $validateColumns. This is a security guard—if you are seeing it for legitimate input, your JSON keys are unusually named; access them by their normal name (meta->tags[0], not meta->'tags').

  • Operator ‘in’ is not allowed for type ‘date’ (or boolean)
    Check your $filterable type for that key. The in operator only works on integer, id, float, string, json, array, and relationship—not on date/datetime/boolean 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 scopeFilterName() (recommended) or scopeName() method on the model. If neither method exists, the trait throws Scope method 'scopeFilterName' or 'scopeName' not found on model. rather than silently skipping the filter.

  • Slow queries on large tables

    • Check if you’re using %…% wildcards (leading %) on very large text columns—those can’t use indexes.
    • Consider adding a full‐text index for complex search scenarios or switch to a dedicated search engine (Scout, Algolia, MeiliSearch).

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!

About

A simple trait for Laravel Eloquent models that allows you to easily filter your queries.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

Laravel Filterable

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.


Table of Contents

  1. Installation
  2. Quick Start
  3. Configuration
  4. Basic Usage
  5. Advanced Filters
  6. Examples
  7. Tips & Best Practices
  8. Troubleshooting

Installation

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.


Quick Start

  1. Add the Filterable trait to your Eloquent model.
  2. Define a protected $filterable array, mapping each filter key to its type.
  3. Call the filter([...]) scope on your queries.
// In app/Models/User.phpnamespaceApp\Models;
useIlluminate\Database\Eloquent\Model;
useFirevel\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();

––

Configuration

Defining $filterable

In 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',
];
  • If a key corresponds to an actual database column, use its column name.
  • If you want “virtual” filters (e.g. 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).


Allowed Filter Types

TypeDescription
integerInteger columns or numeric IDs
idShorthand for integer when representing a primary/foreign key
floatFloating‐point / decimal columns; same operators as integer
stringText columns; used with operators like like, =, <>
dateDate‐only filters (YYYY‐MM‐DD). Under the hood, uses whereDate()
datetimeDate & time filters (YYYY‐MM‐DD HH:MM:SS). Uses whereDate() if value is 10 chars long
booleanCasts “true”/“false” (case‐insensitive) to boolean
jsonJSON columns; used with where() or JSON operators
arrayJSON columns containing arrays; uses whereJsonContains()
relationshipExpect a related model or “has” filter on a belongsTo / hasMany style relationship
scopeCustom composite filter handled by a local scope method — see Composite (“Virtual”) Filters

Supported Operators

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 ]').

OperatorAliasMeaningAllowed Types
=eqEqual to (default if no operator provided)integer, id, float, string, date, datetime, relationship, boolean, json, array
<>neNot equal tointeger, id, float, string
>gtGreater thaninteger, date, datetime, id, float, relationship
>=gteGreater than or equalinteger, date, datetime, id, float, relationship
<ltLess thaninteger, date, datetime, id, float, relationship
<=lteLess than or equalinteger, date, datetime, id, float, relationship
like-SQL LIKE. The value is passed verbatim — include % wildcards yourself (e.g. '%foo%')string
in-SQL IN (for lists or comma‐separated values); for array type, checks if JSON array contains value(s); for relationship type, matches against the related model's primary keyinteger, id, float, string, json, array, relationship
is-IS NULL check (pass 'null' as value)integer, date, datetime, id, float, string, boolean, json, array
not-IS NOT NULL (pass 'null' as value)integer, date, datetime, id, float, string, boolean, json, array

Note: If you supply a plain scalar (e.g. 'foo') instead of ['=' => 'foo'], the trait assumes the = operator by default.

Operator Aliases

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

Validating Columns

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.

Basic Usage

Filtering by Single Field

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';

Filtering by Multiple Fields

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';

Composite (“Virtual”) Filters

Sometimes you want a single filter key (e.g. name) that actually applies to multiple columns (like first_nameORlast_name). You can achieve this by declaring a “scope”-type entry in $filterable and then adding a local scope method on your model.

Example: “name” → searches first_name OR last_name

  1. Declare a scope filter key
    In User.php:

    useIlluminate\Database\Eloquent\Model;
    useFirevel\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 filterspublicfunctionscopeName($query, $value, $allFilters = [])
    {
    $query->where(function ($q) use ($value) {
    $q->where('first_name', 'like', "%{$value}%")
    ->orWhere('last_name', 'like', "%{$value}%");
    });
    }
    }
  2. 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'

Why use a "scope"-type filter?

  • Zero changes to the trait: the existing code already checks if ($filterType === 'scope') and executes the corresponding local scope.
  • Keeps your trait logic simple: you don't have to override the trait's internal validation or operator parsing—your scopeName() takes full responsibility for how the filter behaves.
  • Reusable & readable: everyone knows that "scopeX" is a local query modifier, and the trait simply defers to it.

Scope Method Naming Convention

The trait supports two naming conventions for scope methods and uses Laravel's Str::studly() for conversion:

  1. Prefixed (recommended): scopeFilter{Name} — e.g., scopeFilterSearch for filter key search
  2. Simple: scope{Name} — e.g., scopeSearch for filter key search

The 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 conflictsprotected$filterable = [
'query' => 'scope', // Safe! Won't conflict with $query->query()
];
publicfunctionscopeFilterQuery($query, $value, $allFilters = [])
{
// Your custom filter logic
}
// Also supported for backward compatibilityprotected$filterable = [
'name' => 'scope',
];
publicfunctionscopeName($query, $value, $allFilters = [])
{
// Your custom filter logic
}

Filter names with underscores are converted using StudlyCase:

Filter KeyPrefixed MethodSimple Method
searchscopeFilterSearchscopeSearch
user_statusscopeFilterUserStatusscopeUserStatus

Note: dots are not converted the same way—Str::studly() only treats - and _ as word separators, so a key like user.role would studly-case to User.role (literal dot kept), which can never match a real PHP method name. Don't use dots in 'scope'-type filter keys; use underscores instead (e.g. user_role).

Accessing Other Filters in Scope Methods

Scope filter methods receive two parameters:

  1. $value - The specific value for this filter
  2. $allFilters - The complete array of all filters being applied

This allows you to create conditional logic based on other filters:

protected$filterable = [
'search' => 'scope',
'category' => 'string',
'status' => 'string',
];
publicfunctionscopeSearch($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 presentif (isset($allFilters['category'])) {
$q->orWhere('tags', 'like', "%{$value}%");
}
});
}

Advanced Filters

Filtering JSON Columns

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"]';

Filtering Relationships

If you want to filter on related models (e.g. User hasMany Order), declare the key as relationship in $filterable. Then pass either:

  1. A scalar/array (for simple has() checks).
  2. A nested filter array to apply conditions on the related model.
// In User.phpprotected$filterable = [
'email' => 'string',
'orders' => 'relationship',
'orders.status' => 'string', // related columns are declared with dot notation
];
// 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';

Dot notation composes with JSON -> paths, so you can filter on a nested value inside a related model's JSON column:

// In User.phpprotected$filterable = [
'orders' => 'relationship',
'orders.details' => 'json',
];
$users = User::filter([ 'orders.details->type' => 'priority' ])->get();
// → SELECT * FROM users// WHERE EXISTS (// SELECT 1 FROM orders// WHERE orders.user_id = users.id// AND json_extract(orders.details, '$.type') = 'priority'// );

Tip: If you need a more complex subquery on the relationship, you can chain useRelationshipQuery() before calling filter().

// 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();

For a relationship-type filter, in matches against the related model's primary key (so [1,2,3] above means "has an order whose id is 1, 2, or 3"), combined with the useRelationshipQuery() closure when one is set.


Boolean & Null Checks

  • 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();

Examples

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();

Tips & Best Practices

  • Keep $filterable up to date: Every column or relationship you wish to filter on must appear in the array.
  • Use strict column validation in production:
    protected$validateColumns = true;
    This prevents typos or malicious filters from silently being ignored.
  • Leverage composite (virtual) filters sparingly: Only create a custom scope if you truly need to combine two or more columns into one semantic filter.
  • Avoid leading wildcards unless necessary:
    • LIKE '%foo%' is flexible but slow on large tables. Whenever possible, use LIKE 'foo%' or full‐text search.
  • Paginate filtered results: Filtering can return large result sets. Always pair with →paginate() or →simplePaginate() to avoid memory issues.
  • Test your JSON and relationship filters thoroughly—wrong syntax or missing indexes can lead to unexpected results or performance hits.

Troubleshooting

  • “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.

  • “Illegal filter name ‘…’.”
    The filter key contained a character that a real column, relationship or JSON path never needs. Filter values are always bound as query parameters, but the key is written into the SQL itself, so keys are restricted to word characters, . (relationships), -> (JSON paths) and [] (array indexes). Quotes, spaces and other punctuation are rejected regardless of $validateColumns. This is a security guard—if you are seeing it for legitimate input, your JSON keys are unusually named; access them by their normal name (meta->tags[0], not meta->'tags').

  • Operator ‘in’ is not allowed for type ‘date’ (or boolean)
    Check your $filterable type for that key. The in operator only works on integer, id, float, string, json, array, and relationship—not on date/datetime/boolean 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 scopeFilterName() (recommended) or scopeName() method on the model. If neither method exists, the trait throws Scope method 'scopeFilterName' or 'scopeName' not found on model. rather than silently skipping the filter.

  • Slow queries on large tables

    • Check if you’re using %…% wildcards (leading %) on very large text columns—those can’t use indexes.
    • Consider adding a full‐text index for complex search scenarios or switch to a dedicated search engine (Scout, Algolia, MeiliSearch).

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!

About

A simple trait for Laravel Eloquent models that allows you to easily filter your queries.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Laravel Filterable

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.


Table of Contents

  1. Installation
  2. Quick Start
  3. Configuration
  4. Basic Usage
  5. Advanced Filters
  6. Examples
  7. Tips & Best Practices
  8. Troubleshooting

Installation

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.


Quick Start

  1. Add the Filterable trait to your Eloquent model.
  2. Define a protected $filterable array, mapping each filter key to its type.
  3. Call the filter([...]) scope on your queries.
// In app/Models/User.phpnamespaceApp\Models;
useIlluminate\Database\Eloquent\Model;
useFirevel\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();

––

Configuration

Defining $filterable

In 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',
];
  • If a key corresponds to an actual database column, use its column name.
  • If you want “virtual” filters (e.g. 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).


Allowed Filter Types

TypeDescription
integerInteger columns or numeric IDs
idShorthand for integer when representing a primary/foreign key
floatFloating‐point / decimal columns; same operators as integer
stringText columns; used with operators like like, =, <>
dateDate‐only filters (YYYY‐MM‐DD). Under the hood, uses whereDate()
datetimeDate & time filters (YYYY‐MM‐DD HH:MM:SS). Uses whereDate() if value is 10 chars long
booleanCasts “true”/“false” (case‐insensitive) to boolean
jsonJSON columns; used with where() or JSON operators
arrayJSON columns containing arrays; uses whereJsonContains()
relationshipExpect a related model or “has” filter on a belongsTo / hasMany style relationship
scopeCustom composite filter handled by a local scope method — see Composite (“Virtual”) Filters

Supported Operators

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 ]').

OperatorAliasMeaningAllowed Types
=eqEqual to (default if no operator provided)integer, id, float, string, date, datetime, relationship, boolean, json, array
<>neNot equal tointeger, id, float, string
>gtGreater thaninteger, date, datetime, id, float, relationship
>=gteGreater than or equalinteger, date, datetime, id, float, relationship
<ltLess thaninteger, date, datetime, id, float, relationship
<=lteLess than or equalinteger, date, datetime, id, float, relationship
like-SQL LIKE. The value is passed verbatim — include % wildcards yourself (e.g. '%foo%')string
in-SQL IN (for lists or comma‐separated values); for array type, checks if JSON array contains value(s); for relationship type, matches against the related model's primary keyinteger, id, float, string, json, array, relationship
is-IS NULL check (pass 'null' as value)integer, date, datetime, id, float, string, boolean, json, array
not-IS NOT NULL (pass 'null' as value)integer, date, datetime, id, float, string, boolean, json, array

Note: If you supply a plain scalar (e.g. 'foo') instead of ['=' => 'foo'], the trait assumes the = operator by default.

Operator Aliases

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

Validating Columns

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.

Basic Usage

Filtering by Single Field

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';

Filtering by Multiple Fields

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';

Composite (“Virtual”) Filters

Sometimes you want a single filter key (e.g. name) that actually applies to multiple columns (like first_nameORlast_name). You can achieve this by declaring a “scope”-type entry in $filterable and then adding a local scope method on your model.

Example: “name” → searches first_name OR last_name

  1. Declare a scope filter key
    In User.php:

    useIlluminate\Database\Eloquent\Model;
    useFirevel\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 filterspublicfunctionscopeName($query, $value, $allFilters = [])
    {
    $query->where(function ($q) use ($value) {
    $q->where('first_name', 'like', "%{$value}%")
    ->orWhere('last_name', 'like', "%{$value}%");
    });
    }
    }
  2. 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'

Why use a "scope"-type filter?

  • Zero changes to the trait: the existing code already checks if ($filterType === 'scope') and executes the corresponding local scope.
  • Keeps your trait logic simple: you don't have to override the trait's internal validation or operator parsing—your scopeName() takes full responsibility for how the filter behaves.
  • Reusable & readable: everyone knows that "scopeX" is a local query modifier, and the trait simply defers to it.

Scope Method Naming Convention

The trait supports two naming conventions for scope methods and uses Laravel's Str::studly() for conversion:

  1. Prefixed (recommended): scopeFilter{Name} — e.g., scopeFilterSearch for filter key search
  2. Simple: scope{Name} — e.g., scopeSearch for filter key search

The 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 conflictsprotected$filterable = [
'query' => 'scope', // Safe! Won't conflict with $query->query()
];
publicfunctionscopeFilterQuery($query, $value, $allFilters = [])
{
// Your custom filter logic
}
// Also supported for backward compatibilityprotected$filterable = [
'name' => 'scope',
];
publicfunctionscopeName($query, $value, $allFilters = [])
{
// Your custom filter logic
}

Filter names with underscores are converted using StudlyCase:

Filter KeyPrefixed MethodSimple Method
searchscopeFilterSearchscopeSearch
user_statusscopeFilterUserStatusscopeUserStatus

Note: dots are not converted the same way—Str::studly() only treats - and _ as word separators, so a key like user.role would studly-case to User.role (literal dot kept), which can never match a real PHP method name. Don't use dots in 'scope'-type filter keys; use underscores instead (e.g. user_role).

Accessing Other Filters in Scope Methods

Scope filter methods receive two parameters:

  1. $value - The specific value for this filter
  2. $allFilters - The complete array of all filters being applied

This allows you to create conditional logic based on other filters:

protected$filterable = [
'search' => 'scope',
'category' => 'string',
'status' => 'string',
];
publicfunctionscopeSearch($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 presentif (isset($allFilters['category'])) {
$q->orWhere('tags', 'like', "%{$value}%");
}
});
}

Advanced Filters

Filtering JSON Columns

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"]';

Filtering Relationships

If you want to filter on related models (e.g. User hasMany Order), declare the key as relationship in $filterable. Then pass either:

  1. A scalar/array (for simple has() checks).
  2. A nested filter array to apply conditions on the related model.
// In User.phpprotected$filterable = [
'email' => 'string',
'orders' => 'relationship',
'orders.status' => 'string', // related columns are declared with dot notation
];
// 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';

Dot notation composes with JSON -> paths, so you can filter on a nested value inside a related model's JSON column:

// In User.phpprotected$filterable = [
'orders' => 'relationship',
'orders.details' => 'json',
];
$users = User::filter([ 'orders.details->type' => 'priority' ])->get();
// → SELECT * FROM users// WHERE EXISTS (// SELECT 1 FROM orders// WHERE orders.user_id = users.id// AND json_extract(orders.details, '$.type') = 'priority'// );

Tip: If you need a more complex subquery on the relationship, you can chain useRelationshipQuery() before calling filter().

// 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();

For a relationship-type filter, in matches against the related model's primary key (so [1,2,3] above means "has an order whose id is 1, 2, or 3"), combined with the useRelationshipQuery() closure when one is set.


Boolean & Null Checks

  • 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();

Examples

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();

Tips & Best Practices

  • Keep $filterable up to date: Every column or relationship you wish to filter on must appear in the array.
  • Use strict column validation in production:
    protected$validateColumns = true;
    This prevents typos or malicious filters from silently being ignored.
  • Leverage composite (virtual) filters sparingly: Only create a custom scope if you truly need to combine two or more columns into one semantic filter.
  • Avoid leading wildcards unless necessary:
    • LIKE '%foo%' is flexible but slow on large tables. Whenever possible, use LIKE 'foo%' or full‐text search.
  • Paginate filtered results: Filtering can return large result sets. Always pair with →paginate() or →simplePaginate() to avoid memory issues.
  • Test your JSON and relationship filters thoroughly—wrong syntax or missing indexes can lead to unexpected results or performance hits.

Troubleshooting

  • “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.

  • “Illegal filter name ‘…’.”
    The filter key contained a character that a real column, relationship or JSON path never needs. Filter values are always bound as query parameters, but the key is written into the SQL itself, so keys are restricted to word characters, . (relationships), -> (JSON paths) and [] (array indexes). Quotes, spaces and other punctuation are rejected regardless of $validateColumns. This is a security guard—if you are seeing it for legitimate input, your JSON keys are unusually named; access them by their normal name (meta->tags[0], not meta->'tags').

  • Operator ‘in’ is not allowed for type ‘date’ (or boolean)
    Check your $filterable type for that key. The in operator only works on integer, id, float, string, json, array, and relationship—not on date/datetime/boolean 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 scopeFilterName() (recommended) or scopeName() method on the model. If neither method exists, the trait throws Scope method 'scopeFilterName' or 'scopeName' not found on model. rather than silently skipping the filter.

  • Slow queries on large tables

    • Check if you’re using %…% wildcards (leading %) on very large text columns—those can’t use indexes.
    • Consider adding a full‐text index for complex search scenarios or switch to a dedicated search engine (Scout, Algolia, MeiliSearch).

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!

About

A simple trait for Laravel Eloquent models that allows you to easily filter your queries.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Laravel Filterable

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.


Table of Contents

  1. Installation
  2. Quick Start
  3. Configuration
  4. Basic Usage
  5. Advanced Filters
  6. Examples
  7. Tips & Best Practices
  8. Troubleshooting

Installation

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.


Quick Start

  1. Add the Filterable trait to your Eloquent model.
  2. Define a protected $filterable array, mapping each filter key to its type.
  3. Call the filter([...]) scope on your queries.
// In app/Models/User.phpnamespaceApp\Models;
useIlluminate\Database\Eloquent\Model;
useFirevel\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();

––

Configuration

Defining $filterable

In 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',
];
  • If a key corresponds to an actual database column, use its column name.
  • If you want “virtual” filters (e.g. 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).


Allowed Filter Types

TypeDescription
integerInteger columns or numeric IDs
idShorthand for integer when representing a primary/foreign key
floatFloating‐point / decimal columns; same operators as integer
stringText columns; used with operators like like, =, <>
dateDate‐only filters (YYYY‐MM‐DD). Under the hood, uses whereDate()
datetimeDate & time filters (YYYY‐MM‐DD HH:MM:SS). Uses whereDate() if value is 10 chars long
booleanCasts “true”/“false” (case‐insensitive) to boolean
jsonJSON columns; used with where() or JSON operators
arrayJSON columns containing arrays; uses whereJsonContains()
relationshipExpect a related model or “has” filter on a belongsTo / hasMany style relationship
scopeCustom composite filter handled by a local scope method — see Composite (“Virtual”) Filters

Supported Operators

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 ]').

OperatorAliasMeaningAllowed Types
=eqEqual to (default if no operator provided)integer, id, float, string, date, datetime, relationship, boolean, json, array
<>neNot equal tointeger, id, float, string
>gtGreater thaninteger, date, datetime, id, float, relationship
>=gteGreater than or equalinteger, date, datetime, id, float, relationship
<ltLess thaninteger, date, datetime, id, float, relationship
<=lteLess than or equalinteger, date, datetime, id, float, relationship
like-SQL LIKE. The value is passed verbatim — include % wildcards yourself (e.g. '%foo%')string
in-SQL IN (for lists or comma‐separated values); for array type, checks if JSON array contains value(s); for relationship type, matches against the related model's primary keyinteger, id, float, string, json, array, relationship
is-IS NULL check (pass 'null' as value)integer, date, datetime, id, float, string, boolean, json, array
not-IS NOT NULL (pass 'null' as value)integer, date, datetime, id, float, string, boolean, json, array

Note: If you supply a plain scalar (e.g. 'foo') instead of ['=' => 'foo'], the trait assumes the = operator by default.

Operator Aliases

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

Validating Columns

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.

Basic Usage

Filtering by Single Field

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';

Filtering by Multiple Fields

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';

Composite (“Virtual”) Filters

Sometimes you want a single filter key (e.g. name) that actually applies to multiple columns (like first_nameORlast_name). You can achieve this by declaring a “scope”-type entry in $filterable and then adding a local scope method on your model.

Example: “name” → searches first_name OR last_name

  1. Declare a scope filter key
    In User.php:

    useIlluminate\Database\Eloquent\Model;
    useFirevel\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 filterspublicfunctionscopeName($query, $value, $allFilters = [])
    {
    $query->where(function ($q) use ($value) {
    $q->where('first_name', 'like', "%{$value}%")
    ->orWhere('last_name', 'like', "%{$value}%");
    });
    }
    }
  2. 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'

Why use a "scope"-type filter?

  • Zero changes to the trait: the existing code already checks if ($filterType === 'scope') and executes the corresponding local scope.
  • Keeps your trait logic simple: you don't have to override the trait's internal validation or operator parsing—your scopeName() takes full responsibility for how the filter behaves.
  • Reusable & readable: everyone knows that "scopeX" is a local query modifier, and the trait simply defers to it.

Scope Method Naming Convention

The trait supports two naming conventions for scope methods and uses Laravel's Str::studly() for conversion:

  1. Prefixed (recommended): scopeFilter{Name} — e.g., scopeFilterSearch for filter key search
  2. Simple: scope{Name} — e.g., scopeSearch for filter key search

The 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 conflictsprotected$filterable = [
'query' => 'scope', // Safe! Won't conflict with $query->query()
];
publicfunctionscopeFilterQuery($query, $value, $allFilters = [])
{
// Your custom filter logic
}
// Also supported for backward compatibilityprotected$filterable = [
'name' => 'scope',
];
publicfunctionscopeName($query, $value, $allFilters = [])
{
// Your custom filter logic
}

Filter names with underscores are converted using StudlyCase:

Filter KeyPrefixed MethodSimple Method
searchscopeFilterSearchscopeSearch
user_statusscopeFilterUserStatusscopeUserStatus

Note: dots are not converted the same way—Str::studly() only treats - and _ as word separators, so a key like user.role would studly-case to User.role (literal dot kept), which can never match a real PHP method name. Don't use dots in 'scope'-type filter keys; use underscores instead (e.g. user_role).

Accessing Other Filters in Scope Methods

Scope filter methods receive two parameters:

  1. $value - The specific value for this filter
  2. $allFilters - The complete array of all filters being applied

This allows you to create conditional logic based on other filters:

protected$filterable = [
'search' => 'scope',
'category' => 'string',
'status' => 'string',
];
publicfunctionscopeSearch($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 presentif (isset($allFilters['category'])) {
$q->orWhere('tags', 'like', "%{$value}%");
}
});
}

Advanced Filters

Filtering JSON Columns

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"]';

Filtering Relationships

If you want to filter on related models (e.g. User hasMany Order), declare the key as relationship in $filterable. Then pass either:

  1. A scalar/array (for simple has() checks).
  2. A nested filter array to apply conditions on the related model.
// In User.phpprotected$filterable = [
'email' => 'string',
'orders' => 'relationship',
'orders.status' => 'string', // related columns are declared with dot notation
];
// 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';

Dot notation composes with JSON -> paths, so you can filter on a nested value inside a related model's JSON column:

// In User.phpprotected$filterable = [
'orders' => 'relationship',
'orders.details' => 'json',
];
$users = User::filter([ 'orders.details->type' => 'priority' ])->get();
// → SELECT * FROM users// WHERE EXISTS (// SELECT 1 FROM orders// WHERE orders.user_id = users.id// AND json_extract(orders.details, '$.type') = 'priority'// );

Tip: If you need a more complex subquery on the relationship, you can chain useRelationshipQuery() before calling filter().

// 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();

For a relationship-type filter, in matches against the related model's primary key (so [1,2,3] above means "has an order whose id is 1, 2, or 3"), combined with the useRelationshipQuery() closure when one is set.


Boolean & Null Checks

  • 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();

Examples

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();

Tips & Best Practices

  • Keep $filterable up to date: Every column or relationship you wish to filter on must appear in the array.
  • Use strict column validation in production:
    protected$validateColumns = true;
    This prevents typos or malicious filters from silently being ignored.
  • Leverage composite (virtual) filters sparingly: Only create a custom scope if you truly need to combine two or more columns into one semantic filter.
  • Avoid leading wildcards unless necessary:
    • LIKE '%foo%' is flexible but slow on large tables. Whenever possible, use LIKE 'foo%' or full‐text search.
  • Paginate filtered results: Filtering can return large result sets. Always pair with →paginate() or →simplePaginate() to avoid memory issues.
  • Test your JSON and relationship filters thoroughly—wrong syntax or missing indexes can lead to unexpected results or performance hits.

Troubleshooting

  • “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.

  • “Illegal filter name ‘…’.”
    The filter key contained a character that a real column, relationship or JSON path never needs. Filter values are always bound as query parameters, but the key is written into the SQL itself, so keys are restricted to word characters, . (relationships), -> (JSON paths) and [] (array indexes). Quotes, spaces and other punctuation are rejected regardless of $validateColumns. This is a security guard—if you are seeing it for legitimate input, your JSON keys are unusually named; access them by their normal name (meta->tags[0], not meta->'tags').

  • Operator ‘in’ is not allowed for type ‘date’ (or boolean)
    Check your $filterable type for that key. The in operator only works on integer, id, float, string, json, array, and relationship—not on date/datetime/boolean 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 scopeFilterName() (recommended) or scopeName() method on the model. If neither method exists, the trait throws Scope method 'scopeFilterName' or 'scopeName' not found on model. rather than silently skipping the filter.

  • Slow queries on large tables

    • Check if you’re using %…% wildcards (leading %) on very large text columns—those can’t use indexes.
    • Consider adding a full‐text index for complex search scenarios or switch to a dedicated search engine (Scout, Algolia, MeiliSearch).

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!

About

A simple trait for Laravel Eloquent models that allows you to easily filter your queries.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

Laravel Filterable

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.


Table of Contents

  1. Installation
  2. Quick Start
  3. Configuration
  4. Basic Usage
  5. Advanced Filters
  6. Examples
  7. Tips & Best Practices
  8. Troubleshooting

Installation

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.


Quick Start

  1. Add the Filterable trait to your Eloquent model.
  2. Define a protected $filterable array, mapping each filter key to its type.
  3. Call the filter([...]) scope on your queries.
// In app/Models/User.phpnamespaceApp\Models;
useIlluminate\Database\Eloquent\Model;
useFirevel\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();

––

Configuration

Defining $filterable

In 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',
];
  • If a key corresponds to an actual database column, use its column name.
  • If you want “virtual” filters (e.g. 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).


Allowed Filter Types

TypeDescription
integerInteger columns or numeric IDs
idShorthand for integer when representing a primary/foreign key
floatFloating‐point / decimal columns; same operators as integer
stringText columns; used with operators like like, =, <>
dateDate‐only filters (YYYY‐MM‐DD). Under the hood, uses whereDate()
datetimeDate & time filters (YYYY‐MM‐DD HH:MM:SS). Uses whereDate() if value is 10 chars long
booleanCasts “true”/“false” (case‐insensitive) to boolean
jsonJSON columns; used with where() or JSON operators
arrayJSON columns containing arrays; uses whereJsonContains()
relationshipExpect a related model or “has” filter on a belongsTo / hasMany style relationship
scopeCustom composite filter handled by a local scope method — see Composite (“Virtual”) Filters

Supported Operators

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 ]').

OperatorAliasMeaningAllowed Types
=eqEqual to (default if no operator provided)integer, id, float, string, date, datetime, relationship, boolean, json, array
<>neNot equal tointeger, id, float, string
>gtGreater thaninteger, date, datetime, id, float, relationship
>=gteGreater than or equalinteger, date, datetime, id, float, relationship
<ltLess thaninteger, date, datetime, id, float, relationship
<=lteLess than or equalinteger, date, datetime, id, float, relationship
like-SQL LIKE. The value is passed verbatim — include % wildcards yourself (e.g. '%foo%')string
in-SQL IN (for lists or comma‐separated values); for array type, checks if JSON array contains value(s); for relationship type, matches against the related model's primary keyinteger, id, float, string, json, array, relationship
is-IS NULL check (pass 'null' as value)integer, date, datetime, id, float, string, boolean, json, array
not-IS NOT NULL (pass 'null' as value)integer, date, datetime, id, float, string, boolean, json, array

Note: If you supply a plain scalar (e.g. 'foo') instead of ['=' => 'foo'], the trait assumes the = operator by default.

Operator Aliases

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

Validating Columns

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.

Basic Usage

Filtering by Single Field

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';

Filtering by Multiple Fields

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';

Composite (“Virtual”) Filters

Sometimes you want a single filter key (e.g. name) that actually applies to multiple columns (like first_nameORlast_name). You can achieve this by declaring a “scope”-type entry in $filterable and then adding a local scope method on your model.

Example: “name” → searches first_name OR last_name

  1. Declare a scope filter key
    In User.php:

    useIlluminate\Database\Eloquent\Model;
    useFirevel\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 filterspublicfunctionscopeName($query, $value, $allFilters = [])
    {
    $query->where(function ($q) use ($value) {
    $q->where('first_name', 'like', "%{$value}%")
    ->orWhere('last_name', 'like', "%{$value}%");
    });
    }
    }
  2. 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'

Why use a "scope"-type filter?

  • Zero changes to the trait: the existing code already checks if ($filterType === 'scope') and executes the corresponding local scope.
  • Keeps your trait logic simple: you don't have to override the trait's internal validation or operator parsing—your scopeName() takes full responsibility for how the filter behaves.
  • Reusable & readable: everyone knows that "scopeX" is a local query modifier, and the trait simply defers to it.

Scope Method Naming Convention

The trait supports two naming conventions for scope methods and uses Laravel's Str::studly() for conversion:

  1. Prefixed (recommended): scopeFilter{Name} — e.g., scopeFilterSearch for filter key search
  2. Simple: scope{Name} — e.g., scopeSearch for filter key search

The 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 conflictsprotected$filterable = [
'query' => 'scope', // Safe! Won't conflict with $query->query()
];
publicfunctionscopeFilterQuery($query, $value, $allFilters = [])
{
// Your custom filter logic
}
// Also supported for backward compatibilityprotected$filterable = [
'name' => 'scope',
];
publicfunctionscopeName($query, $value, $allFilters = [])
{
// Your custom filter logic
}

Filter names with underscores are converted using StudlyCase:

Filter KeyPrefixed MethodSimple Method
searchscopeFilterSearchscopeSearch
user_statusscopeFilterUserStatusscopeUserStatus

Note: dots are not converted the same way—Str::studly() only treats - and _ as word separators, so a key like user.role would studly-case to User.role (literal dot kept), which can never match a real PHP method name. Don't use dots in 'scope'-type filter keys; use underscores instead (e.g. user_role).

Accessing Other Filters in Scope Methods

Scope filter methods receive two parameters:

  1. $value - The specific value for this filter
  2. $allFilters - The complete array of all filters being applied

This allows you to create conditional logic based on other filters:

protected$filterable = [
'search' => 'scope',
'category' => 'string',
'status' => 'string',
];
publicfunctionscopeSearch($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 presentif (isset($allFilters['category'])) {
$q->orWhere('tags', 'like', "%{$value}%");
}
});
}

Advanced Filters

Filtering JSON Columns

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"]';

Filtering Relationships

If you want to filter on related models (e.g. User hasMany Order), declare the key as relationship in $filterable. Then pass either:

  1. A scalar/array (for simple has() checks).
  2. A nested filter array to apply conditions on the related model.
// In User.phpprotected$filterable = [
'email' => 'string',
'orders' => 'relationship',
'orders.status' => 'string', // related columns are declared with dot notation
];
// 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';

Dot notation composes with JSON -> paths, so you can filter on a nested value inside a related model's JSON column:

// In User.phpprotected$filterable = [
'orders' => 'relationship',
'orders.details' => 'json',
];
$users = User::filter([ 'orders.details->type' => 'priority' ])->get();
// → SELECT * FROM users// WHERE EXISTS (// SELECT 1 FROM orders// WHERE orders.user_id = users.id// AND json_extract(orders.details, '$.type') = 'priority'// );

Tip: If you need a more complex subquery on the relationship, you can chain useRelationshipQuery() before calling filter().

// 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();

For a relationship-type filter, in matches against the related model's primary key (so [1,2,3] above means "has an order whose id is 1, 2, or 3"), combined with the useRelationshipQuery() closure when one is set.


Boolean & Null Checks

  • 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();

Examples

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();

Tips & Best Practices

  • Keep $filterable up to date: Every column or relationship you wish to filter on must appear in the array.
  • Use strict column validation in production:
    protected$validateColumns = true;
    This prevents typos or malicious filters from silently being ignored.
  • Leverage composite (virtual) filters sparingly: Only create a custom scope if you truly need to combine two or more columns into one semantic filter.
  • Avoid leading wildcards unless necessary:
    • LIKE '%foo%' is flexible but slow on large tables. Whenever possible, use LIKE 'foo%' or full‐text search.
  • Paginate filtered results: Filtering can return large result sets. Always pair with →paginate() or →simplePaginate() to avoid memory issues.
  • Test your JSON and relationship filters thoroughly—wrong syntax or missing indexes can lead to unexpected results or performance hits.

Troubleshooting

  • “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.

  • “Illegal filter name ‘…’.”
    The filter key contained a character that a real column, relationship or JSON path never needs. Filter values are always bound as query parameters, but the key is written into the SQL itself, so keys are restricted to word characters, . (relationships), -> (JSON paths) and [] (array indexes). Quotes, spaces and other punctuation are rejected regardless of $validateColumns. This is a security guard—if you are seeing it for legitimate input, your JSON keys are unusually named; access them by their normal name (meta->tags[0], not meta->'tags').

  • Operator ‘in’ is not allowed for type ‘date’ (or boolean)
    Check your $filterable type for that key. The in operator only works on integer, id, float, string, json, array, and relationship—not on date/datetime/boolean 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 scopeFilterName() (recommended) or scopeName() method on the model. If neither method exists, the trait throws Scope method 'scopeFilterName' or 'scopeName' not found on model. rather than silently skipping the filter.

  • Slow queries on large tables

    • Check if you’re using %…% wildcards (leading %) on very large text columns—those can’t use indexes.
    • Consider adding a full‐text index for complex search scenarios or switch to a dedicated search engine (Scout, Algolia, MeiliSearch).

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!

About

A simple trait for Laravel Eloquent models that allows you to easily filter your queries.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages