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.5.0(1mo ago)0363↓50%MITPHPPHP ^8.2 || ^8.3 || ^8.4

Since Apr 9Pushed 2mo 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 3w ago

READMEChangelogDependencies (8)Versions (11)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)
    - [SearchQuery::aggregate()](#searchqueryaggregate)
    - [SearchBuilder](#searchbuilder)
- [Aggregation](#aggregation)
    - [Declaring aggregatable fields](#declaring-aggregatable-fields)
    - [Running an aggregation](#running-an-aggregation)
    - [Example JSON request](#example-json-request)
    - [Spec &amp; result shape](#spec--result-shape)
    - [Top-N](#top-n)
    - [Multiple metrics, derived metrics, HAVING &amp; totals](#multiple-metrics-derived-metrics-having--totals)
    - [Expression dimensions (period buckets, weekday, …)](#expression-dimensions-period-buckets-weekday-)
    - [Validation](#validation)
- [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 8.2, 8.3, or 8.4
- Laravel 11, 12, or 13 (`illuminate/database` + `illuminate/support`)

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}``aggregate`objectNoAggregation spec (GROUP BY) — see [Aggregation](#aggregation)> ⚠️ **A valid, well-formed payload is required.** The structure above is strict: wrong types or malformed blocks — `where` that isn't an object, a `between` without exactly two values, a non-integer `page`, etc. — are rejected with `InvalidPayloadException` → HTTP **422**. Unknown *field names* and *operators* are tolerated (silently skipped), but the **shape** of each block is enforced. See [Validation &amp; Error Handling](#validation--error-handling).

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

### SearchQuery::aggregate()

[](#searchqueryaggregate)

Applies the payload's filters and runs the `aggregate` block (GROUP BY) — returns the aggregate rows. Mirror of `apply()`, but the terminal is aggregation instead of pagination. See [Aggregation](#aggregation).

```
$rows = SearchQuery::aggregate($query, $payload, $allowedRelations, $config);
// [['group' => 1, 'value' => 142], ...]  (or [['value' => 327]] without a group-by)
```

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

// Aggregate (GROUP BY) — reads the payload's `aggregate` block; see the Aggregation section
$rows = $builder->aggregate();
```

**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());
}
```

Aggregation
-----------

[](#aggregation)

Aggregation is a **terminal** on `SearchBuilder` — a sibling to `paginate()` / `get()` / `count()`, not a step in the filter pipeline. It reuses the same filtered query (your `where`, full-text `search`, `has`, etc. all apply) and runs a `GROUP BY` + aggregate `SELECT`, returning grouped rows instead of records.

What may be aggregated is declared in `searchableConfig()` as a **closed set** — metric fields and group-by dimensions are validated against the model, so no client-supplied column or SQL ever reaches the database.

### Declaring aggregatable fields

[](#declaring-aggregatable-fields)

```
use Illuminate\Support\Facades\DB;

SearchableConfig::make()
    ->fields([/* ... */])
    // Metrics. Two shapes, mixed freely:
    //  - Column metric: numeric field → which functions it allows (`fn(field)`).
    //  - Expression metric: a name → a model-authored aggregate expression, referenced
    //    from the payload by `{name}`. The SQL lives in YOUR model (trusted), never in
    //    the request, so the closed-set guarantee holds.
    // `count` (of records) is always available and needs no field.
    ->metrics([
        'duration'        => ['sum', 'avg', 'min', 'max'],
        'amount'          => ['sum', 'avg'],
        'shift_count'     => ['expr' => DB::raw('COUNT(*)')],
        'confirmed_count' => ['expr' => DB::raw('SUM(confirmed_by_employee)')],
    ])
    // Dimensions. Column dimensions are string list entries; an expression dimension is a
    // `name => DB::raw(...)` pair — the portable escape hatch for period/weekday/etc.
    // (the driver-specific SQL is your model's code, not the request).
    ->dimensions([
        'project_id', 'status', 'employee_id',
        'day' => DB::raw("DATE_FORMAT(scheduled_time, '%Y-%m-%d')"), // MySQL example
    ])
    // Derived metrics: computed in PHP after aggregation from the values row (keyed by
    // metric name). Portable — it's plain PHP, no SQL.
    ->derived([
        'confirmation_rate' => fn ($v) => ($v['shift_count'] ?? 0) > 0
            ? round(100 * ($v['confirmed_count'] ?? 0) / $v['shift_count'], 2) : 0,
    ]);
```

> The library itself stays database-agnostic — it only emits standard aggregate functions and `GROUP BY`. Anything driver-specific (date bucketing, `JSON_TABLE`, `CONVERT_TZ`, …) belongs in an **expression metric/dimension** that your model declares: the library treats it as opaque SQL it was handed, so portability is the model author's call, not the request's. When a derived metric is requested, every declared expression metric is computed so the closure can read its inputs by name. A derived metric should therefore depend on **expression metrics** (or `count` declared as one) — a plain column metric (`fn`+`field`) is only present in the closure's row when it is itself explicitly requested.

### Running an aggregation

[](#running-an-aggregation)

The aggregate spec lives **inside the payload** under the `aggregate` key — the whole JSON goes into the builder, exactly like the rest of the DSL. `where`/`or`/`has` filter the data, then the `aggregate` block runs the GROUP BY:

```
$payload = [
    'where'     => ['eq' => ['status' => 'done']],
    'aggregate' => [
        'metric'  => ['fn' => 'sum', 'field' => 'duration'],
        'groupBy' => ['field' => 'project_id'],
    ],
];

$rows = SearchQuery::aggregate(Shift::query()->where('company_id', $companyId), $payload);

// [
//   ['group' => 1, 'value' => 142],
//   ['group' => 2, 'value' => 96],
//   ['group' => 3, 'value' => 58],
// ]
```

Equivalent via the builder (e.g. to add constraints before running):

```
$rows = SearchQuery::build(Shift::query(), $payload)->aggregate();
```

### Example JSON request

[](#example-json-request)

The request is a single, well-formed payload — filters and the `aggregate` block together — sent as-is to the builder:

```
// POST /shifts/aggregate
{
  "where": {
    "eq": { "status": "done" },
    "between": { "scheduled_time": ["2026-05-01 00:00:00", "2026-05-31 23:59:59"] }
  },
  "aggregate": {
    "metric":    { "fn": "sum", "field": "duration" },
    "groupBy":   { "field": "project_id" },
    "orderBy":   "value",
    "direction": "desc",
    "limit":     5
  }
}
```

```
public function aggregate(Request $request)
{
    $rows = SearchQuery::aggregate(
        Shift::query()->where('company_id', $request->user()->company_id),
        $request->all()
    );

    return response()->json($rows);
}
```

```
[
  { "group": 1, "value": 142 },
  { "group": 2, "value": 96 },
  { "group": 3, "value": 58 }
]
```

**Rich request (JSON)** — multiple metrics (column + expression + derived), multi-level grouping, `having` (incl. `between`), top-N ordering and a grand total, all in one payload:

```
// POST /shifts/aggregate
{
  "where": {
    "between": { "scheduled_time": ["2026-05-01 00:00:00", "2026-05-31 23:59:59"] }
  },
  "aggregate": {
    "metrics": [
      { "fn": "count", "as": "shift_count" },
      { "name": "confirmed_count", "as": "confirmed_count" },
      { "name": "confirmation_rate", "as": "rate" }
    ],
    "groupBy": [
      { "field": "project_id" },
      { "field": "status" }
    ],
    "having": [
      { "metric": "shift_count", "op": "between", "value": [10, 500] }
    ],
    "orderBy": "rate",
    "direction": "desc",
    "limit": 20,
    "total": true
  }
}
```

Response — `values` keyed by `as`, `group` is an object (multi-level), and a trailing grand-total row with `group: null`:

```
[
  { "group": { "project_id": 1, "status": "done" }, "values": { "shift_count": 42, "confirmed_count": 40, "rate": 95.24 } },
  { "group": { "project_id": 2, "status": "done" }, "values": { "shift_count": 31, "confirmed_count": 25, "rate": 80.65 } },
  { "group": null, "values": { "shift_count": 327, "confirmed_count": 300, "rate": 91.74 } }
]
```

**Period trend (JSON)** — group by the `day` expression dimension declared on the model:

```
// POST /shifts/aggregate
{
  "where": {
    "between": { "scheduled_time": ["2026-05-01 00:00:00", "2026-05-31 23:59:59"] }
  },
  "aggregate": {
    "metric":    { "fn": "count" },
    "groupBy":   { "field": "day" },
    "orderBy":   "group",
    "direction": "asc"
  }
}
```

```
[
  { "group": "2026-05-01", "value": 12 },
  { "group": "2026-05-02", "value": 9 },
  { "group": "2026-05-03", "value": 15 }
]
```

### Spec &amp; result shape

[](#spec--result-shape)

KeyTypeDescription`metric`objectone metric (BC). `{fn:'count'}`, `{fn:'sum'|'avg'|'min'|'max', field}`, or `{name}` (declared expression metric)`metrics`array**many** metrics: a list of metric objects, each with an optional `as` (output key). Use instead of `metric``groupBy`object | array | nullomit → scalar aggregate; `{field}` → one group-by; `[{field}, …]` → multi-level group-by (≤ `max_group_by_depth`)`having`arraypost-aggregation conditions on metric values: `[{metric:'', op, value}]`. `op ∈ eq/not_eq/gt/gte/lt/lte/between` (derived-aware)`orderBy`string`'group'` or a metric `as`. (`'value'` is accepted as a legacy alias when using singular `metric`)`direction`string`'asc'` or `'desc'` (default: `desc` for a metric, `asc` for group)`limit`inttop-N rows`total`boolwith `groupBy`, append a grand-total row (`group: null`) re-aggregated over the full set**Result — the shape mirrors the request:**

RequestEach row`metric` (singular)`{group?, value}``metrics` (plural)`{group?, values: {as: number}}`no `groupBy`no `group` key (single scalar / KPI row)one `groupBy``group` = scalarmany `groupBy``group` = `{dimension: value, …}`Values come back as numbers — `int` for whole results, `float` for fractional ones (e.g. `avg`).

```
// Single value (KPI) — payload: ['aggregate' => ['metric' => ['fn' => 'count']]]
SearchQuery::aggregate(Shift::query(), $payload);
// [['value' => 327]]
```

### Top-N

[](#top-n)

Order by a metric and limit the rows (in the payload's `aggregate` block):

```
$payload = [
    'aggregate' => [
        'metric'    => ['fn' => 'sum', 'field' => 'duration'],
        'groupBy'   => ['field' => 'project_id'],
        'orderBy'   => 'value',
        'direction' => 'desc',
        'limit'     => 5,
    ],
];

$rows = SearchQuery::aggregate(Shift::query(), $payload);
```

### Multiple metrics, derived metrics, HAVING &amp; totals

[](#multiple-metrics-derived-metrics-having--totals)

Request several metrics at once (output keyed by `as`), filter grouped rows with `having`, and append a grand total. Derived metrics and totals are computed correctly for non-additive metrics (`avg`/`min`/`max`/`distinct`) because the total is **re-aggregated over the whole set**, not summed from the group rows.

```
$payload = [
    'aggregate' => [
        'metrics' => [
            ['name' => 'shift_count',       'as' => 'shift_count'],     // expression metric
            ['name' => 'confirmed_count',   'as' => 'confirmed_count'], // expression metric
            ['name' => 'confirmation_rate', 'as' => 'rate'],            // derived (PHP)
        ],
        'groupBy' => [['field' => 'project_id'], ['field' => 'status']],         // multi-level
        'having'  => [['metric' => 'shift_count', 'op' => 'gte', 'value' => 10]],
        'orderBy' => 'rate',
        'direction' => 'desc',
        'total'   => true,
    ],
];

$rows = SearchQuery::aggregate(Shift::query()->where('company_id', $id), $payload);

// [
//   ['group' => ['project_id' => 1, 'status' => 'done'], 'values' => ['shift_count' => 42, 'confirmed_count' => 40, 'rate' => 95.24]],
//   ...
//   ['group' => null, 'values' => ['shift_count' => 327, 'confirmed_count' => 300, 'rate' => 91.74]], // grand total
// ]
```

`having` is applied in PHP after aggregation, so it works on derived metrics too. The grand-total row ignores `having`/`limit` (it always covers the full filtered set).

`having` also accepts `between`:

```
// projects whose shift_count is in [10, 100]
'having' => [['metric' => 'shift_count', 'op' => 'between', 'value' => [10, 100]]],
```

### Expression dimensions (period buckets, weekday, …)

[](#expression-dimensions-period-buckets-weekday-)

Date bucketing has no portable SQL form, so the library doesn't ship it — but a model can declare an **expression dimension** (the `day => DB::raw(...)` entry shown in [Declaring aggregatable fields](#declaring-aggregatable-fields)) and group by it like any other dimension. The driver-specific SQL stays in your model; the request just names it.

```
// Trend: shifts per day in May. `day` is the expression dimension declared on the model.
$payload = [
    'where'     => ['between' => ['scheduled_time' => ['2026-05-01 00:00:00', '2026-05-31 23:59:59']]],
    'aggregate' => [
        'metric'    => ['fn' => 'count'],
        'groupBy'   => ['field' => 'day'],
        'orderBy'   => 'group',
        'direction' => 'asc',
    ],
];

$rows = SearchQuery::aggregate(Shift::query()->where('company_id', $id), $payload);

// [
//   ['group' => '2026-05-01', 'value' => 12],
//   ['group' => '2026-05-02', 'value' => 9],
//   ['group' => '2026-05-03', 'value' => 15],
//   ...
// ]
```

The same mechanism covers `weekday`, `start_hour`, or any other computed grouping — declare the expression once, then it's a first-class dimension (usable in `groupBy`, single or multi-level).

### Validation

[](#validation)

The spec is validated against the config; an invalid request throws `InvalidPayloadException` (HTTP 422):

- an unknown function (not one of `count` / `sum` / `avg` / `min` / `max`);
- a metric `field` not declared in `metrics()`, or not allowing that function;
- a metric `{name}` not declared as an expression metric or a derived metric;
- a `groupBy` field not declared in `dimensions()` (column or expression);
- more `groupBy` levels than `limits.max_group_by_depth` (default 3);
- a `having` condition referencing an unselected metric, or using an operator outside `eq/not_eq/gt/gte/lt/lte/between`;
- an `orderBy` that is neither `'group'` nor a selected metric.

Since every metric, dimension and HAVING target is checked against the closed config set, no client-supplied identifier is interpolated into raw SQL — only the SQL **you** declared in expression metrics/dimensions runs.

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
        'max_group_by_depth' => 3,     // Max GROUP BY levels in an aggregation
        'max_groups' => 5000,          // Hard ceiling on grouped rows fetched before PHP post-processing
    ],

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

45

—

FairBetter than 91% of packages

Maintenance88

Actively maintained with recent releases

Popularity17

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity58

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

10

Last Release

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

[tucker-eric/eloquentfilter

An Eloquent way to filter Eloquent Models

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

Psalm plugin for Laravel

3345.3M347](/packages/psalm-plugin-laravel)[yajra/laravel-oci8

Oracle DB driver for Laravel via OCI8

8793.2M25](/packages/yajra-laravel-oci8)[mohammad-fouladgar/eloquent-builder

526198.2k](/packages/mohammad-fouladgar-eloquent-builder)

PHPackages © 2026

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