dartvadius / eloquent-search
Universal JSON query DSL parser for Laravel Eloquent
Requires
- php: ^8.2
- illuminate/database: ^11.0
- illuminate/support: ^11.0
Requires (Dev)
- orchestra/testbench: ^9.0
- phpunit/phpunit: ^11.0
README
Universal JSON query DSL parser for Laravel Eloquent. Accepts a structured JSON payload and converts it into Eloquent queries with filtering, sorting, pagination, full-text search, relation filtering, and JSON field support.
Table of Contents
- Requirements
- Installation
- Quick Start
- How It Works
- Model Configuration
- JSON Payload Format
- API Reference
- Operator Auto-Resolution
- Custom Filters
- Configuration
- Validation & Error Handling
- Security Considerations
Requirements
- PHP >= 8.2
- Laravel >= 11.0
Installation
Add the package to your project via Composer:
composer require dartvadius/eloquent-search
Laravel auto-discovers the service provider. To publish the config file:
php artisan vendor:publish --tag=eloquent-search-config
This creates config/eloquent-search.php with default settings.
Quick Start
1. Prepare your model
Add the Searchable trait and define searchableConfig():
use DartVadius\EloquentSearch\Searchable; use DartVadius\EloquentSearch\SearchableConfig; class Task extends Model { use Searchable; public function searchableConfig(): SearchableConfig { return SearchableConfig::make() ->fields(['id', 'title', 'employee_id', 'scheduled_time', 'created_at']) ->nullable(['employee_id']) ->sortable(['id', 'scheduled_time', 'created_at']) ->defaultSort('scheduled_time', 'asc'); } }
2. Use in a controller
use DartVadius\EloquentSearch\SearchQuery; public function search(Request $request) { $query = Task::where('company_id', $request->user()->company_id); $result = SearchQuery::apply($query, $request->json()->all()); return response()->json($result); }
3. Send a JSON request
POST /api/tasks/search Content-Type: application/json { "where": { "eq": { "employee_id": 42 }, "between": { "scheduled_time": ["2026-04-01 00:00:00", "2026-04-30 23:59:59"] } }, "sort": [{ "field": "scheduled_time", "dir": "desc" }], "page": 1, "per_page": 25 }
Response:
{
"data": [{ "id": 1, "title": "...", "..." }],
"total": 42,
"page": 1,
"per_page": 25,
"last_page": 2
}
How It Works
JSON payload
|
v
PayloadValidator -- validates structure, types, limits
|
v
OperatorResolver -- reads model casts/schema, resolves allowed operators per field
|
v
QueryParser -- converts JSON operators into Eloquent where/whereIn/etc. calls
|
v
SortApplier -- applies sorting or default sort
|
v
SearchPaginator -- paginates or returns count_only
|
v
Eloquent Builder result
The package never touches your base query. You set up any authorization scopes, joins, or conditions you need before passing the Builder to SearchQuery. The DSL is applied on top.
Model Configuration
The searchableConfig() method returns a SearchableConfig instance that defines what's allowed and how.
Fields
The field whitelist controls which columns can be filtered. Fields not in this list are silently ignored (or throw an exception, depending on config).
SearchableConfig::make() ->fields([ 'id', // auto-resolves operators from column type 'title', // string -> eq, not_eq, like, in, not_in 'employee_id', // integer -> eq, not_eq, in, not_in, gt, lt, gte, lte, between 'scheduled_time', // datetime -> eq, between, gt, lt, gte, lte 'is_recurring', // boolean -> eq, not_eq 'status' => ['eq', 'in'], // explicit override: only these operators allowed ]);
Operators are auto-resolved from the model's $casts or the database schema. See Operator Auto-Resolution for the full mapping.
Nullable columns detected from the database schema automatically get the is_null operator added. For columns where schema detection is unreliable (or the column is NOT NULL in the schema but you still need is_null filtering), use the explicit nullable() method below.
Nullable Fields
Explicitly mark fields that should support the is_null operator, regardless of their database schema:
->nullable(['employee_id', 'client_id', 'parent_id'])
This guarantees is_null is available for these fields without relying on database schema introspection. Useful when:
- The column is
NOT NULLin the schema but you need to filter by null values - Schema detection fails due to caching or connection issues
- You want explicit control over which fields support null filtering
Fields listed in nullable() get is_null added to their auto-resolved operators. Schema-based nullable detection still works as a fallback for all other fields.
JSON Fields
Mark columns that store JSON arrays (e.g., tags, marks, skills):
->jsonFields(['marks', 'skills'])
This enables the json_contains and json_contains_all operators for these fields, regardless of their $casts type.
Sorting
Define which fields can appear in the sort payload:
->sortable(['id', 'title', 'scheduled_time', 'created_at']) ->defaultSort('scheduled_time', 'asc')
If the client doesn't send sort or sends only non-whitelisted fields, the defaultSort is applied. If no defaultSort is configured, no sorting is applied.
Search Fields
Define fields for full-text search (the search operator). Supports dot notation for related model fields:
->searchFields(['title', 'employee.first_name', 'employee.last_name'])
When the client sends "search": "John", the query becomes:
WHERE (title LIKE '%John%' OR EXISTS ( SELECT * FROM employees WHERE employees.id = tasks.employee_id AND (first_name LIKE '%John%' OR last_name LIKE '%John%') ))
If searchFields is not configured, the search operator is silently ignored.
Extending Search (searchUsing)
For searching data that cannot be expressed via searchFields (pivot tables, computed fields, custom fields with business logic), use searchUsing():
->searchUsing(function (\Illuminate\Database\Eloquent\Builder $query, string $term) { // Called inside the OR group alongside searchFields. // Use $query->orWhere / orWhereIn / orWhereHas to add conditions. // $term is the original search term (not escaped). })
The callback is invoked inside the shared WHERE (...) search group, so all conditions are OR-combined with the rest of the search fields.
Example: searching custom fields on tasks
// In the controller — company_id is available $config = (new Task)->searchableConfig() ->searchUsing(function (Builder $query, string $term) use ($company) { $searchableFieldIds = TaskCustomField::where('company_id', $company->id) ->where('searchable', true) ->pluck('id'); if ($searchableFieldIds->isEmpty()) { return; } $taskIds = TaskCustomFieldValue::whereIn('task_custom_field_id', $searchableFieldIds) ->where('value', 'LIKE', "%{$term}%") ->pluck('task_id') ->toArray(); if (!empty($taskIds)) { $query->orWhereIn('id', $taskIds); } }); // Pass the augmented config to build() $builder = SearchQuery::build($query, $payload, [], $config);
You can register multiple callbacks — all will be invoked within the same OR group:
$config = (new Task)->searchableConfig() ->searchUsing($this->customFieldSearchCallback($company)) ->searchUsing($this->anotherSearchCallback());
When to use searchUsing instead of searchFields:
- Searching pivot tables (custom fields, tags via intermediate table)
- Searching with business logic (e.g., only fields with the
searchableflag) - Searching computed values or subqueries
- When controller context is needed (
$company,$user)
Relations
Define which model relations can be filtered via the has operator:
->relations([ 'latestLog' => ['status_id'], 'client' => ['name', 'email', 'phone'], 'employee' => ['id', 'first_name', 'last_name'], ])
Each relation maps to an array of allowed fields within that relation. Fields not in this list are ignored.
Custom Filters
Register custom logic for fields that require non-standard queries:
->filter('task_status', new TaskStatusFilter())
See Custom Filters section for details.
JSON Payload Format
The payload is a JSON object with these top-level keys:
| Key | Type | Required | Description |
|---|---|---|---|
where |
object | No | AND filter conditions |
or |
object | No | OR filter conditions (combined with where as WHERE (where) OR (or)) |
sort |
array | No | Sorting rules |
search |
string | No | Full-text search term (can also be inside where) |
page |
integer | No | Page number (enables pagination) |
per_page |
integer | No | Results per page (default: 25, max: 1000) |
count_only |
boolean | No | If true, returns only {"total": N} |
Operators
All operators are keys inside the where (or or) object. Each operator maps field names to values:
{
"where": {
"OPERATOR": {
"FIELD": "VALUE"
}
}
}
Comparison operators
| Operator | SQL | Value type | Example |
|---|---|---|---|
eq |
= value |
scalar | {"eq": {"status": "active"}} |
not_eq |
!= value |
scalar | {"not_eq": {"status": "cancelled"}} |
gt |
> value |
scalar | {"gt": {"id": 100}} |
gte |
>= value |
scalar | {"gte": {"price": 50.00}} |
lt |
< value |
scalar | {"lt": {"id": 1000}} |
lte |
<= value |
scalar | {"lte": {"price": 200.00}} |
Set operators
| Operator | SQL | Value type | Example |
|---|---|---|---|
in |
IN (...) |
non-empty array | {"in": {"employee_id": [1, 2, 3]}} |
not_in |
NOT IN (...) |
non-empty array | {"not_in": {"status": [5, 6]}} |
Range operator
| Operator | SQL | Value type | Example |
|---|---|---|---|
between |
BETWEEN a AND b |
array of 2 elements | {"between": {"scheduled_time": ["2026-04-01", "2026-04-30"]}} |
String operator
| Operator | SQL | Value type | Example |
|---|---|---|---|
like |
LIKE '%value%' |
string | {"like": {"title": "repair"}} |
The % wrapping is automatic. Special characters (%, _, !) are auto-escaped.
Null operators
| Operator | SQL | Value type | Example |
|---|---|---|---|
is_null |
IS NULL / IS NOT NULL |
boolean or array | {"is_null": {"cancelled_at": true}} |
Object format: {"is_null": {"field": true}} — true = IS NULL, false = IS NOT NULL.
Array shorthand: {"is_null": ["field1", "field2"]} — all listed fields must be NULL. Equivalent to {"is_null": {"field1": true, "field2": true}}.
JSON operators
| Operator | SQL | Logic | Example |
|---|---|---|---|
json_contains |
JSON_CONTAINS |
ANY (OR) | {"json_contains": {"marks": [1, 2]}} — has mark 1 OR 2 |
json_contains_all |
JSON_CONTAINS |
ALL (AND) | {"json_contains_all": {"skills": [5, 10]}} — has skill 5 AND 10 |
Both accept a scalar (single value) or an array (multiple values).
Combining operators
All operators within a where block are combined with AND:
{
"where": {
"eq": { "employee_id": 42 },
"between": { "scheduled_time": ["2026-04-01", "2026-04-30"] },
"is_null": { "cancelled_at": true }
}
}
Generates:
WHERE employee_id = 42 AND scheduled_time BETWEEN '2026-04-01' AND '2026-04-30' AND cancelled_at IS NULL
Sorting
{
"sort": [
{ "field": "scheduled_time", "dir": "desc" },
{ "field": "id", "dir": "asc" }
]
}
field(required) must be in the model'ssortable()whitelistdir(optional) defaults to"asc". Valid values:"asc","desc"- Non-whitelisted fields are silently skipped
- If no valid sort provided,
defaultSort()is used as fallback
Pagination
{
"page": 1,
"per_page": 50
}
When page is present, the response includes pagination metadata:
{
"data": [...],
"total": 150,
"page": 1,
"per_page": 50,
"last_page": 3
}
When page is absent, SearchQuery::apply() returns the same paginated format with page: 1. Use SearchQuery::build() + ->get() for unpaginated results.
Count Only
{
"count_only": true,
"where": { "eq": { "employee_id": 42 } }
}
Response:
{
"total": 15
}
No data is returned. Useful for badge counters, tab counts, etc.
Full-Text Search
Search can be a top-level key or inside where:
{
"where": {
"search": "John repair",
"between": { "scheduled_time": ["2026-04-01", "2026-04-30"] }
}
}
The search term is matched against all fields defined in searchFields(). For related fields (dot notation like employee.first_name), the library uses whereHas automatically. To extend search beyond standard fields, use searchUsing().
OR Conditions
Use the or block alongside where for (where) OR (or) logic:
{
"where": {
"eq": { "employee_id": 42 }
},
"or": {
"eq": { "created_by": 42 }
}
}
Generates:
WHERE (employee_id = 42) OR (created_by = 42)
The or block supports all the same operators as where. Nesting or inside or is not allowed.
AND-OR Groups
For complex conditions like "employee is 42 OR client is in [10, 20]":
{
"where": {
"and_or": [
{ "eq": { "employee_id": 42 } },
{ "in": { "client_id": [10, 20] } }
]
}
}
Each array element is an OR group. Groups are combined with AND:
WHERE (employee_id = 42) AND (client_id IN (10, 20))
Within a single group with multiple conditions, the first is AND and the rest are OR:
{
"and_or": [
{ "eq": { "employee_id": 42, "created_by": 42 } }
]
}
WHERE (employee_id = 42 OR created_by = 42)
Relation Filtering (has)
Filter records based on related model data using has:
{
"where": {
"has": {
"latestLog": {
"in": { "status_id": [1, 2, 3] }
},
"client": {
"like": { "name": "Acme" },
"load": false
}
}
}
}
Generates:
WHERE EXISTS (SELECT * FROM task_logs WHERE task_logs.task_id = tasks.id AND status_id IN (1, 2, 3)) AND EXISTS (SELECT * FROM clients WHERE clients.id = tasks.client_id AND name LIKE '%Acme%')
Each relation key maps to an object of operators that apply to the related table's fields.
Eager loading: By default, filtered relations are added to with() for eager loading. Set "load": false to filter without loading the relation data.
Whitelisting: Relations must be defined in both:
- The model's
->relations([...])config (with allowed fields) - The controller's
$allowedRelationsarray (if passed toSearchQuery)
API Reference
SearchQuery::apply()
All-in-one: validates, filters, sorts, and paginates.
$result = SearchQuery::apply( $query, // Eloquent Builder $payload, // JSON payload (array) $allowedRelations // optional: ['relation1', 'relation2'] ); // $result = ['data' => [...], 'total' => 42, 'page' => 1, 'per_page' => 25, 'last_page' => 2]
SearchQuery::build()
Step-by-step: validates, filters, and sorts, but does NOT paginate. Returns a SearchBuilder for manual control.
$builder = SearchQuery::build($query, $payload, $allowedRelations);
The optional fourth parameter $config overrides the model's config — useful for adding searchUsing in the controller:
$config = (new Task)->searchableConfig() ->searchUsing($this->customFieldSearchCallback($company)); $builder = SearchQuery::build($query, $payload, [], $config);
SearchBuilder
Returned by SearchQuery::build(). Provides access to the modified query:
// Get the raw Eloquent Builder (for further modifications) $eloquentQuery = $builder->getQuery(); // Execute and get all results (no pagination) $collection = $builder->get(); // Get paginated results (same format as apply()) $result = $builder->paginate(); // Get count only $count = $builder->count();
Typical pattern for unpaginated results:
$builder = SearchQuery::build($query, $payload); $tasks = $builder->get(); return response()->json($tasks);
Typical pattern for conditional pagination:
$builder = SearchQuery::build($query, $payload); if (isset($payload['page'])) { return response()->json($builder->paginate()); } else { return response()->json($builder->get()); }
Operator Auto-Resolution
The library automatically determines which operators are valid for each field based on the model's $casts and database column types:
| Type | Auto-resolved operators |
|---|---|
integer, bigint, smallint, etc. |
eq, not_eq, in, not_in, gt, lt, gte, lte, between |
float, double, decimal |
eq, not_eq, in, not_in, gt, lt, gte, lte, between |
string |
eq, not_eq, like, in, not_in |
boolean |
eq, not_eq |
datetime, timestamp |
eq, between, gt, lt, gte, lte |
date |
eq, between, gt, lt, gte, lte |
array, collection, json |
json_contains, json_contains_all |
Additionally:
- Nullable columns detected from the database schema get
is_nulladded automatically nullable()explicitly addsis_nullfor listed fields, regardless of schemajsonFields()forcesjson_contains+json_contains_allregardless of cast type- Explicit overrides (
'status' => ['eq', 'in']) bypass auto-resolution entirely
Custom Filters
For queries that cannot be expressed through the standard operators (subqueries, computed fields, cross-table aggregation), implement the CustomFilter interface:
use Illuminate\Database\Eloquent\Builder; use DartVadius\EloquentSearch\Contracts\CustomFilter; class TaskStatusFilter implements CustomFilter { public function apply(Builder $query, string $operator, mixed $value): void { // Example: filter by latest log's status via subquery $subquery = '(SELECT status_id FROM task_logs WHERE task_id = tasks.id ORDER BY id DESC LIMIT 1)'; match ($operator) { 'eq' => $query->where(\DB::raw($subquery), $value), 'in' => $query->whereIn(\DB::raw($subquery), (array) $value), 'not_in' => $query->whereNotIn(\DB::raw($subquery), (array) $value), }; } public function allowedOperators(): array { return ['eq', 'in', 'not_in']; } }
Register in the model config:
public function searchableConfig(): SearchableConfig { return SearchableConfig::make() ->fields(['id', 'title', 'scheduled_time']) ->filter('task_status', new TaskStatusFilter()); }
Use in JSON payload:
{
"where": {
"in": { "task_status": [1, 2, 3] }
}
}
Important: Custom filters are registered by field name (not operator). When a field matches a custom filter, all operator handling is delegated to that filter. The allowedOperators() method controls which operators the filter accepts.
Configuration
After publishing (php artisan vendor:publish --tag=eloquent-search-config), edit config/eloquent-search.php:
return [ // Pagination defaults 'pagination' => [ 'default_per_page' => 25, // Default page size 'max_per_page' => 1000, // Maximum allowed page size (silently capped) ], // Safety limits to prevent abuse 'limits' => [ 'max_conditions' => 50, // Max total conditions across where + or + and_or + has 'max_or_conditions' => 10, // Max groups in and_or 'max_in_values' => 500, // Max array items in in/not_in/json_contains ], // What to do with fields not in the whitelist 'on_unknown_field' => 'skip', // 'skip' = silently ignore, 'throw' = throw InvalidPayloadException ];
Validation & Error Handling
The library validates the payload structure before executing any queries. Invalid payloads throw DartVadius\EloquentSearch\Exceptions\InvalidPayloadException (extends Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException).
In a Laravel application, this means invalid payloads automatically return a 422 Unprocessable Entity HTTP response — no manual try/catch required.
What is validated:
| Check | Error |
|---|---|
where / or is not an object |
"where" must be an object. |
Nested or inside or |
Nested "or" inside "or" is not supported. |
Nested has inside has |
Nested "has" is not supported. |
Nested and_or inside and_or |
Nested "and_or" inside and_or is not supported. |
eq value is array |
eq.field in where: expected scalar, got array. |
in value is empty |
in.field in where: expected non-empty array. |
between value has != 2 elements |
between.field in where: expected array with exactly 2 elements. |
like value is not string |
like.field in where: expected string, got integer. |
is_null value is not boolean/array |
is_null.field in where: expected boolean, got string. |
is_null array shorthand has non-string |
is_null[1] in where: expected string field name, got integer. |
| Total conditions > max | Too many conditions: 55 (max: 50). |
in values > max |
in.field in where: too many values 600 (max: 500). |
and_or groups > max |
Too many "and_or" groups in where: 15 (max: 10). |
Invalid page |
"page" must be a positive integer. |
Invalid sort[].dir |
"sort[0].dir" must be "asc" or "desc". |
What is NOT validated (silently ignored):
- Unknown field names (when
on_unknown_field=skip) - Operators not allowed for a field type (e.g.,
likeon an integer) - Unknown relation names in
has - Non-whitelisted sort fields
searchwhen nosearchFieldsconfiguredsearchwith empty string ornullvalue (treated as no search)
Since InvalidPayloadException extends UnprocessableEntityHttpException, Laravel handles it automatically — returning 422 with the error message. No try/catch needed in controllers.
If you need custom error formatting, you can still catch it explicitly:
use DartVadius\EloquentSearch\Exceptions\InvalidPayloadException; try { $result = SearchQuery::apply($query, $payload); } catch (InvalidPayloadException $e) { return response()->json(['error' => $e->getMessage()], 422); }
Security Considerations
- Field whitelisting is mandatory. Only fields declared in
fields()can be queried. There is no "allow all" mode. - Relation whitelisting is double-gated. Both the model config and the controller's
$allowedRelationsmust allow a relation. - Input limits prevent abuse.
max_conditions,max_in_values, andmax_or_conditionsprotect against denial-of-service via complex queries. - Always apply authorization scopes before passing the query. The library does not handle permissions. Set up your
WHERE company_id = ?or role-based scopes on the Builder before callingSearchQuery.
// Good: authorization first, then DSL $query = Task::where('company_id', $user->company_id); $result = SearchQuery::apply($query, $payload); // Bad: no authorization scope $result = SearchQuery::apply(Task::query(), $payload);