laravel-queryable maintained by omoba
laravel-queryable
Declarative search, filter, and sort scopes for Eloquent models — with first-class support for relationship traversal via dot notation.
User::search('john')
->filter([
'status' => 'active,pending',
'created_at' => ['from' => '2025-01-01', 'to' => '2025-12-31'],
'company.industry' => 'fintech',
])
->sort('-created_at,name')
->paginate();
Requirements
- PHP 8.2+
- Laravel 10, 11, or 12
Installation
composer require omoba/laravel-queryable
The service provider is auto-discovered. Optionally publish the config:
php artisan vendor:publish --tag=queryable-config
Quick start
Add the Queryable trait to your model and implement the three declaration methods:
use Omoba\LaravelQueryable\Concerns\Queryable;
use Omoba\LaravelQueryable\Operators\FilterOperator;
class User extends Model
{
use Queryable;
public function searchable(): array
{
return ['name', 'email', 'company.name'];
}
public function filterable(): array
{
return [
'status' => FilterOperator::In,
'created_at' => FilterOperator::DateRange,
];
}
public function sortable(): array
{
return ['name', 'created_at'];
}
}
Then in your controller:
public function index(Request $request): JsonResponse
{
return response()->json(
User::query()
->search($request->string('q')->toString() ?: null)
->filter($request->array('filter'))
->sort($request->string('sort')->toString() ?: null)
->paginate($request->integer('per_page', 25))
);
}
Traits
Use Queryable for all three features, or mix in individual traits:
| Trait | Scopes added |
|---|---|
Queryable |
search, searchEncrypted, filter, filterHaving, sort |
Searchable |
search, searchEncrypted |
Filterable |
filter, filterHaving |
Sortable |
sort |
use Omoba\LaravelQueryable\Concerns\Filterable;
use Omoba\LaravelQueryable\Concerns\Sortable;
class Product extends Model
{
use Filterable, Sortable;
// No searchable() needed — Searchable trait is not used
}
Each trait requires you to implement its declaration method(s). The compiler will tell you which ones are missing.
Declaration methods
searchable(): array
Required by Searchable. Return column names (or dot-notation relation paths) to include in substring search.
public function searchable(): array
{
return [
'name',
'email',
'company.name', // one level deep
'team.company.name', // two levels deep
];
}
searchableEncrypted(): array
Optional — defaults to []. Columns that store SHA-256 hashed values. Used by searchEncrypted().
public function searchableEncrypted(): array
{
return ['phone_hash'];
}
hashSearchTerm(string $term): string
Optional override. Defaults to hash('sha256', $term). Override to change the hashing strategy.
public function hashSearchTerm(string $term): string
{
return hash('sha256', strtolower(trim($term)));
}
filterable(): array
Required by Filterable. Maps field names to filter operators. Three declaration styles:
public function filterable(): array
{
return [
// Explicit operator via enum
'name' => FilterOperator::Like,
'email' => FilterOperator::Exact,
'status' => FilterOperator::In,
'created_at' => FilterOperator::DateRange,
'archived_at' => FilterOperator::Null,
'amount' => FilterOperator::Gte,
// Explicit operator via string (case-insensitive)
'score' => 'gte',
// No operator — inferred from the incoming value shape at runtime
'category',
'updated_at',
];
}
When no operator is declared, the package infers one: an array with from/to keys becomes between; anything else becomes exact.
Dot notation works the same as in searchable():
public function filterable(): array
{
return [
'company.industry' => FilterOperator::Exact,
'team.company.name' => FilterOperator::Like,
];
}
having(): array
Optional — defaults to []. Same shape as filterable(), but for columns in a HAVING clause (e.g. aggregates from withCount / selectRaw). Used by filterHaving().
public function having(): array
{
return [
'posts_count' => FilterOperator::Between,
];
}
sortable(): array
Required by Sortable. An indexed list of column names permitted for sorting.
public function sortable(): array
{
return ['name', 'created_at', 'email'];
}
Scopes
search(?string $term)
Case-insensitive substring match across all columns declared in searchable(). Columns in related tables use orWhereHas. Returns the unmodified query if $term is null or empty.
Uses ILIKE on PostgreSQL and LIKE on all other drivers.
User::search('john')->get();
// WHERE (name LIKE '%john%' OR email LIKE '%john%' OR EXISTS (SELECT ... company.name LIKE '%john%'))
searchEncrypted(?string $term)
Hashes $term (default: SHA-256) and performs an exact match against columns in searchableEncrypted(). Returns the unmodified query if $term is null or empty.
User::searchEncrypted('+15550100')->first();
// WHERE phone_hash = 'a1b2c3...'
filter(?array $filters)
Applies WHERE clauses for each key in $filters against the filterable() map. Silently skips null and empty-string values.
User::filter([
'status' => 'active',
'created_at' => ['from' => '2025-01-01', 'to' => '2025-12-31'],
])->get();
filterHaving(?array $filters)
Same as filter() but emits HAVING clauses. Use after aggregating with withCount, selectRaw, etc.
User::withCount('posts')
->filterHaving(['posts_count' => ['from' => 5]])
->get();
// HAVING posts_count >= 5
sort(string|array|null $spec)
Applies ORDER BY for each field in $spec. The field must be declared in sortable(). Returns the unmodified query on null or empty input.
// String form — prefix with '-' for descending
User::sort('-created_at,name')->get();
// ORDER BY created_at DESC, name ASC
// Associative array
User::sort(['created_at' => 'desc', 'name' => 'asc'])->get();
// Indexed array
User::sort(['-created_at', 'name'])->get();
Filter operators
| Operator | Enum constant | Value shape | SQL |
|---|---|---|---|
exact |
FilterOperator::Exact |
'foo' · 'a,b,c' · ['a','b','c'] |
= ? or IN (...) |
like |
FilterOperator::Like |
'foo' |
LIKE '%foo%' |
in |
FilterOperator::In |
'a,b,c' · ['a','b','c'] |
IN (...) |
between |
FilterOperator::Between |
['from' => x, 'to' => y] (each side optional) |
BETWEEN · >= · <= |
date_range |
FilterOperator::DateRange |
same as between, parsed as Carbon dates |
BETWEEN · >= · <= |
null |
FilterOperator::Null |
(no user value needed) | IS NULL |
gt |
FilterOperator::Gt |
scalar | > ? |
gte |
FilterOperator::Gte |
scalar | >= ? |
lt |
FilterOperator::Lt |
scalar | < ? |
lte |
FilterOperator::Lte |
scalar | <= ? |
Value sentinels
Any field — regardless of its declared operator — recognises two special string values:
| Sent value | SQL emitted |
|---|---|
'null' |
IS NULL |
'not_null' |
IS NOT NULL |
This lets API clients check for nullability without a separate endpoint:
GET /users?filter[archived_at]=null → WHERE archived_at IS NULL
GET /users?filter[archived_at]=not_null → WHERE archived_at IS NOT NULL
CSV shorthand
For exact and in operators, a comma-separated string expands to an IN clause:
GET /users?filter[status]=active,pending
→ WHERE status IN ('active', 'pending')
Relationship traversal
Dot notation in searchable() and filterable() traverses Eloquent relations of any depth. The package uses whereHas / orWhereHas internally:
public function searchable(): array
{
return [
'name',
'company.name', // whereHas('company', fn($q) => $q->orWhere('name', ...))
'team.company.name', // whereHas('team.company', fn($q) => $q->orWhere('name', ...))
];
}
public function filterable(): array
{
return [
'company.industry' => FilterOperator::Exact,
'team.company.name' => FilterOperator::Like,
];
}
Relation sorting is not supported in this version — it requires joins that risk duplicate rows and column-name collisions. Use a raw orderBy with an explicit join if you need it.
Strict mode
By default, an unknown key in filter() / filterHaving() / sort() throws an exception.
| Exception | Thrown when |
|---|---|
InvalidFilterField |
Key not declared in filterable() / having() |
InvalidSortField |
Field not declared in sortable() |
Disable strict mode to silently skip unknown keys — useful for public APIs where clients may send extra parameters:
// config/queryable.php
return ['strict' => false];
// or in .env
QUERYABLE_STRICT=false
Chaining with native Eloquent
All scopes return the builder, so they compose freely with any Eloquent method:
User::query()
->whereNotNull('email_verified_at')
->with(['company', 'profile'])
->search($request->string('q')->toString() ?: null)
->filter($request->array('filter'))
->sort($request->string('sort')->toString() ?: null)
->paginate(25);
HAVING example
User::query()
->withCount('posts')
->search($request->string('q')->toString() ?: null)
->filter($request->array('filter'))
->filterHaving($request->array('having'))
->sort($request->string('sort')->toString() ?: null)
->paginate(25);
Full model example
use Omoba\LaravelQueryable\Concerns\Queryable;
use Omoba\LaravelQueryable\Operators\FilterOperator;
class Transaction extends Model
{
use Queryable;
public function searchable(): array
{
return ['reference', 'pocket.user.email', 'pocket.user.first_name'];
}
public function searchableEncrypted(): array
{
return [];
}
public function filterable(): array
{
return [
'type' => FilterOperator::Exact,
'category' => FilterOperator::Exact,
'status' => FilterOperator::In,
'created_at' => FilterOperator::DateRange,
'amount' => FilterOperator::Gte,
];
}
public function sortable(): array
{
return ['created_at', 'amount'];
}
}
Controller:
public function index(Request $request): JsonResponse
{
$transactions = Transaction::query()
->search($request->string('q')->toString() ?: null)
->filter($request->array('filter'))
->sort($request->string('sort')->toString() ?: null)
->with('pocket.user')
->paginate($request->integer('per_page', 25));
return response()->json($transactions);
}
Example requests:
# Search across reference and user name/email
GET /transactions?q=john
# Exact filter
GET /transactions?filter[status]=completed
# IN filter (CSV)
GET /transactions?filter[status]=completed,pending
# Date range
GET /transactions?filter[created_at][from]=2026-01-01&filter[created_at][to]=2026-05-25
# Minimum amount
GET /transactions?filter[amount]=10000
# Sort — newest first, then by amount ascending
GET /transactions?sort=-created_at,amount
# Null sentinel
GET /transactions?filter[category]=null
# Combined
GET /transactions?q=jane&filter[status]=completed&filter[created_at][from]=2026-01-01&sort=-created_at
Comparison with spatie/laravel-query-builder
Spatie's package is broader in scope (filters, fields, includes, sorts, custom filters). This package takes a different approach:
- Model-side declaration —
filterable(),sortable(),searchable()live on the model, not in a fluent chain at the call site. - First-class
searchas its own concept, distinct from a filter. - No request coupling — scopes accept plain PHP values, not a
Requestobject.
Pick whichever fits your team's mental model.
Testing
composer install
composer test # PHPUnit
composer stan # PHPStan level 8 + Larastan
vendor/bin/pint --test # code style check
License
MIT