PHPackages                             dartvadius/eloquent-search - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Database &amp; ORM](/categories/database)
4. /
5. dartvadius/eloquent-search

ActiveLibrary[Database &amp; ORM](/categories/database)

dartvadius/eloquent-search
==========================

Universal JSON query DSL parser for Laravel Eloquent

v1.3.0(4w ago)0206↓21.6%MITPHPPHP ^8.2 || ^8.3 || ^8.4

Since Apr 9Pushed 4w agoCompare

[ Source](https://github.com/DartVadius/eloquent-search)[ Packagist](https://packagist.org/packages/dartvadius/eloquent-search)[ RSS](/packages/dartvadius-eloquent-search/feed)WikiDiscussions main Synced 1w ago

READMEChangelogDependencies (4)Versions (7)Used By (0)

Eloquent Search
===============

[](#eloquent-search)

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

[](#table-of-contents)

- [Requirements](#requirements)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [How It Works](#how-it-works)
- [Model Configuration](#model-configuration)
    - [Fields](#fields)
    - [Nullable Fields](#nullable-fields)
    - [JSON Fields](#json-fields)
    - [Sorting](#sorting)
    - [Search Fields](#search-fields)
    - [Extending Search (searchUsing)](#extending-search-searchusing)
    - [Relations](#relations)
    - [Custom Filters](#custom-filters)
- [JSON Payload Format](#json-payload-format)
    - [Operators](#operators)
    - [Sorting](#sorting-1)
    - [Pagination](#pagination)
    - [Count Only](#count-only)
    - [Full-Text Search](#full-text-search)
    - [OR Conditions](#or-conditions)
    - [AND-OR Groups](#and-or-groups)
    - [Relation Filtering (has)](#relation-filtering-has)
- [API Reference](#api-reference)
    - [SearchQuery::apply()](#searchqueryapply)
    - [SearchQuery::build()](#searchquerybuild)
    - [SearchBuilder](#searchbuilder)
- [Operator Auto-Resolution](#operator-auto-resolution)
- [Custom Filters](#custom-filters-1)
- [Configuration](#configuration)
- [Validation &amp; Error Handling](#validation--error-handling)
- [Security Considerations](#security-considerations)

Requirements
------------

[](#requirements)

- PHP &gt;= 8.2
- Laravel &gt;= 11.0

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

[](#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
-----------

[](#quick-start)

### 1. Prepare your model

[](#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

[](#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

[](#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
------------

[](#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
-------------------

[](#model-configuration)

The `searchableConfig()` method returns a `SearchableConfig` instance that defines what's allowed and how.

### Fields

[](#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](#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

[](#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 NULL` in 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

[](#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

[](#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

[](#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)

[](#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 `searchable` flag)
- Searching computed values or subqueries
- When controller context is needed (`$company`, `$user`)

### Relations

[](#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

[](#custom-filters)

Register custom logic for fields that require non-standard queries:

```
->filter('task_status', new TaskStatusFilter())
```

See [Custom Filters](#custom-filters-1) section for details.

JSON Payload Format
-------------------

[](#json-payload-format)

The payload is a JSON object with these top-level keys:

KeyTypeRequiredDescription`where`objectNoAND filter conditions`or`objectNoOR filter conditions (combined with `where` as `WHERE (where) OR (or)`)`sort`arrayNoSorting rules`search`stringNoFull-text search term (can also be inside `where`)`page`integerNoPage number (enables pagination)`per_page`integerNoResults per page (default: 25, max: 1000)`count_only`booleanNoIf true, returns only `{"total": N}`### Operators

[](#operators)

All operators are keys inside the `where` (or `or`) object. Each operator maps field names to values:

```
{
    "where": {
        "OPERATOR": {
            "FIELD": "VALUE"
        }
    }
}
```

#### Comparison operators

[](#comparison-operators)

OperatorSQLValue typeExample`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`` [...], 'total' => 42, 'page' => 1, 'per_page' => 25, 'last_page' => 2]
```

### SearchQuery::build()

[](#searchquerybuild)

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

[](#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
------------------------

[](#operator-auto-resolution)

The library automatically determines which operators are valid for each field based on the model's `$casts` and database column types:

TypeAuto-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_null` added automatically
- **`nullable()`** explicitly adds `is_null` for listed fields, regardless of schema
- **`jsonFields()`** forces `json_contains` + `json_contains_all` regardless of cast type
- **Explicit overrides** (`'status' => ['eq', 'in']`) bypass auto-resolution entirely

Custom Filters
--------------

[](#custom-filters-1)

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

[](#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 &amp; Error Handling
-------------------------------

[](#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:**

CheckError`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 &gt; max`Too many conditions: 55 (max: 50).``in` values &gt; max`in.field in where: too many values 600 (max: 500).``and_or` groups &gt; 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., `like` on an integer)
- Unknown relation names in `has`
- Non-whitelisted sort fields
- `search` when no `searchFields` configured
- `search` with empty string or `null` value (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
-----------------------

[](#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 `$allowedRelations` must allow a relation.
- **Input limits prevent abuse.** `max_conditions`, `max_in_values`, and `max_or_conditions` protect 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 calling `SearchQuery`.

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

###  Health Score

46

—

FairBetter than 92% of packages

Maintenance94

Actively maintained with recent releases

Popularity16

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity55

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~6 days

Total

6

Last Release

28d ago

PHP version history (2 changes)v1.0.0PHP ^8.2

v1.3.0PHP ^8.2 || ^8.3 || ^8.4

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/21615434?v=4)[Vadym](/maintainers/DartVadius)[@DartVadius](https://github.com/DartVadius)

---

Top Contributors

[![DartVadius](https://avatars.githubusercontent.com/u/21615434?v=4)](https://github.com/DartVadius "DartVadius (6 commits)")

---

Tags

jsonsearchlaraveleloquentfilterDSLquery builder

###  Code Quality

TestsPHPUnit

### Embed Badge

![Health badge](/badges/dartvadius-eloquent-search/health.svg)

```
[![Health](https://phpackages.com/badges/dartvadius-eloquent-search/health.svg)](https://phpackages.com/packages/dartvadius-eloquent-search)
```

###  Alternatives

[kirschbaum-development/eloquent-power-joins

The Laravel magic applied to joins.

1.6k29.9M42](/packages/kirschbaum-development-eloquent-power-joins)[tucker-eric/eloquentfilter

An Eloquent way to filter Eloquent Models

1.8k5.0M31](/packages/tucker-eric-eloquentfilter)[psalm/plugin-laravel

Psalm plugin for Laravel

3325.1M337](/packages/psalm-plugin-laravel)[mohammad-fouladgar/eloquent-builder

526196.6k](/packages/mohammad-fouladgar-eloquent-builder)[mehdi-fathi/eloquent-filter

Eloquent Filter adds custom filters automatically to your Eloquent Models in Laravel.It's easy to use and fully dynamic, just with sending the Query Strings to it.

448196.7k1](/packages/mehdi-fathi-eloquent-filter)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
