kalimulhaq / qubuilder
A Laravel package that converts structured JSON filter arrays into Eloquent query builder chains — enabling dynamic filtering, sorting, pagination, and eager loading via API requests.
Requires
- php: ^8.3
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpunit/phpunit: ^11.3
Suggests
- illuminate/foundation: Required to use the GetCollectionRequest and GetResourceRequest form request base classes.
Provides
None
Conflicts
None
Replaces
None
README
Qubuilder
Qubuilder turns a structured filter payload into a fully chained Eloquent query: no if chains in every endpoint, no hand-written request parsers. Send the filters in an HTTP request (GET, POST or QUERY) or pass a plain PHP array, and get back a Builder ready to paginate.
$users = Qubuilder::makeFromRequest(request(), User::class)->query()->paginate();
What it does
| Key | What it does |
|---|---|
select |
Returns only the columns the client asks for |
filter |
Nested AND/OR groups with more than 20 operators: comparisons, lists, null checks, text search, dates and times, JSON columns, column-to-column comparison, relationship existence, and matching across several columns |
include |
Eager-loads relations with their own filters, sorting and nested includes, or computes a count, sum, average, minimum or maximum instead |
sort |
Orders by several columns, including raw expressions |
group |
Groups the results |
page / limit |
Paginates, with a hard cap you set so no request can ask for more rows than you allow |
What it supports
- PHP 8.3 or newer, and Laravel 11, 12 and 13.
- Input from a query string (GET), a JSON body (POST, or the HTTP QUERY method), or a plain PHP array.
- Relations, including nested and polymorphic (MorphTo) relations.
- Limits you control: the page-size cap, and switches to require explicit columns or to turn includes off.
Installation
composer require kalimulhaq/qubuilder
The service provider and facade are auto-discovered by Laravel.
Publish Config
php artisan vendor:publish --provider="Kalimulhaq\Qubuilder\QubuilderServiceProvider" --tag="config"
Configuration
config/qubuilder.php
return [ // When false: `select` becomes required (top level and inside each // non-aggregate include), the "*" wildcard is rejected, and the builder // falls back to the model's primary key when no columns are given. 'allow_select_all' => true, // When false: the `include` parameter is stripped from the parsed input, // its validation is skipped, and no relations are ever eager-loaded. 'allow_include' => true, /* |-------------------------------------------------------------------------- | HTTP Parameter Names |-------------------------------------------------------------------------- | Customise the URL parameter names the package reads from the request. | Set a key to null to use the default name shown in the comments. */ 'params' => [ 'select' => null, // default: 'select' 'filter' => null, // default: 'filter' 'include' => null, // default: 'include' 'sort' => null, // default: 'sort' 'group' => null, // default: 'group' 'page' => null, // default: 'page' 'limit' => null, // default: 'limit' ], 'limit' => [ 'default' => 15, // records per page when not specified 'max' => 50, // hard cap — requests above this are clamped ], ];
Limit clamping: Any
limitvalue abovemaxis silently clamped tomax. Values of0or below are also clamped tomax(not to1or the default), so sendinglimit=0returnsmaxrecords.
allow_select_all(defaulttrue): leave enabled to keep the current behaviour where an omittedselectreturns all columns. Set tofalseto force clients to request explicit columns —selectbecomes required at the top level and inside every non-aggregate include, the"*"wildcard is rejected by validation (a table-qualifiedusers.*is dropped by the builder as well), and if a query still reaches the builder without columns it selects the model's primary key only. Aggregate includes (count,avg,sum,min,max) are exempt since they select no columns.
allow_include(defaulttrue): leave enabled to allow relation eager-loading. Set tofalseto disable includes entirely — theincludeparameter is silently stripped from the request (no validation error) and the builder never loads relations, even nested ones.
Quick Start
From a Plain Array
use Kalimulhaq\Qubuilder\Support\Facades\Qubuilder; $filters = [ 'select' => ['id', 'name', 'email'], 'filter' => [ 'AND' => [ ['field' => 'status', 'op' => '=', 'value' => 'active'], ['field' => 'age', 'op' => '>=', 'value' => 18], ], ], 'include' => [ ['name' => 'orders', 'aggregate' => 'count'], ], 'sort' => ['created_at' => 'desc'], 'page' => 1, 'limit' => 20, ]; $users = Qubuilder::make($filters, User::class)->query()->paginate();
From an Existing Builder
Pass any Builder or Relation instance instead of a model class string to apply filters on top of an existing query.
use Kalimulhaq\Qubuilder\Support\Facades\Qubuilder; $builder = User::where('tenant_id', $tenantId); $users = Qubuilder::make($filters, $builder)->query()->paginate();
From an HTTP Request
GET (query string), POST (request body) and QUERY (request body) are supported. The package reads from $request->input(), which handles all three. For a list endpoint that needs a body, prefer QUERY over POST: see The QUERY Method.
use Kalimulhaq\Qubuilder\Support\Facades\Qubuilder; // GET /api/users?filter={"AND":[{"field":"status","op":"=","value":"active"}]}&sort={"created_at":"desc"}&limit=20 // POST /api/users { "filter": {"AND":[{"field":"status","op":"=","value":"active"}]}, "sort": {"created_at":"desc"}, "limit": 20 } // QUERY /api/users { "filter": {"AND":[{"field":"status","op":"=","value":"active"}]}, "sort": {"created_at":"desc"}, "limit": 20 } (Content-Type: application/json) $users = Qubuilder::makeFromRequest(request(), User::class) ->query() ->paginate();
You can also extend the built-in GetCollectionRequest to get automatic validation:
use Kalimulhaq\Qubuilder\Http\Requests\GetCollectionRequest; use Kalimulhaq\Qubuilder\Support\Facades\Qubuilder; class ListUsersRequest extends GetCollectionRequest {} public function index(ListUsersRequest $request) { return Qubuilder::make($request->filters(), User::class) ->query() ->paginate(); }
Detailed Example
A single filters payload that exercises every available option — shown as both a PHP array and its JSON equivalent for use in HTTP requests.
PHP Array
$filters = [ // ── Columns ────────────────────────────────────────────────────────────── 'select' => ['id', 'name', 'email', 'status', 'created_at'], // ── WHERE Conditions ───────────────────────────────────────────────────── 'filter' => [ 'AND' => [ // Comparison operators: = != <> > < >= <= ['field' => 'status', 'op' => '=', 'value' => 'active'], ['field' => 'age', 'op' => '>=', 'value' => 18], ['field' => 'score', 'op' => '!=', 'value' => 0], // List operators ['field' => 'role', 'op' => 'in', 'value' => ['admin', 'editor']], ['field' => 'type', 'op' => 'not_in', 'value' => ['guest', 'banned']], ['field' => 'created_at', 'op' => 'between', 'value' => ['2024-01-01', '2024-12-31']], ['field' => 'score', 'op' => 'not_between', 'value' => [0, 10]], // Null checks (no value needed) ['field' => 'verified_at', 'op' => 'not_null'], ['field' => 'deleted_at', 'op' => 'null'], // auto-applies ->withTrashed() // LIKE / text search ['field' => 'name', 'op' => '_like_', 'value' => 'john'], // LIKE '%john%' ['field' => 'email', 'op' => 'like_', 'value' => 'admin'], // LIKE 'admin%' ['field' => 'bio', 'op' => '_like', 'value' => '.com'], // LIKE '%.com' // Date & time operators ['field' => 'created_at', 'op' => 'date', 'value' => '2024-06-15'], ['field' => 'created_at', 'op' => 'year', 'value' => 2024], ['field' => 'created_at', 'op' => 'month', 'value' => 6], ['field' => 'created_at', 'op' => 'day', 'value' => 15], ['field' => 'published_at', 'op' => 'time', 'value' => '09:00:00'], // JSON column ['field' => 'settings->notifications', 'op' => 'json_contains', 'value' => 'email'], ['field' => 'settings->flags', 'op' => 'json_not_contains', 'value' => 'beta'], // Column-to-column comparison (field|<operator>) ['field' => 'updated_at', 'op' => 'field|>', 'value' => 'created_at'], // Raw WHERE expression ['field' => 'YEAR(created_at) = ?', 'op' => 'raw', 'value' => [2024]], // Relationship existence ['field' => 'orders', 'op' => 'has|>=', 'value' => 3], // has('orders', '>=', 3) [ 'field' => 'orders', 'op' => 'has', 'value' => [ 'AND' => [ ['field' => 'status', 'op' => '=', 'value' => 'completed'], ], ], ], ['field' => 'invoices', 'op' => 'doesnthave'], // doesntHave('invoices') [ 'field' => 'invoices', 'op' => 'doesnthave', 'value' => [ 'AND' => [ ['field' => 'status', 'op' => '=', 'value' => 'cancelled'], ], ], ], // Multi-column operators [ 'field' => ['first_name', 'last_name', 'email'], 'op' => 'any|_like_', 'value' => 'john', ], // whereAny([...], 'like', '%john%') // Nested OR group inside the top-level AND [ 'OR' => [ ['field' => 'country', 'op' => '=', 'value' => 'US'], ['field' => 'country', 'op' => '=', 'value' => 'CA'], ], ], ], ], // ── Eager Loading ───────────────────────────────────────────────────────── 'include' => [ // Basic relation ['name' => 'profile'], // Relation with sub-filters, sort, and limit [ 'name' => 'orders', 'select' => ['id', 'status', 'total'], 'filter' => [ 'AND' => [ ['field' => 'status', 'op' => '=', 'value' => 'completed'], ], ], 'sort' => ['created_at' => 'desc'], 'limit' => 5, ], // Nested include [ 'name' => 'orders', 'include' => [ ['name' => 'items', 'select' => ['id', 'product_id', 'qty']], ], ], // Aggregate: count ['name' => 'orders', 'aggregate' => 'count'], // Aggregate: sum with filter scope [ 'name' => 'orders', 'aggregate' => 'sum', 'field' => 'total', 'filter' => [ 'AND' => [ ['field' => 'status', 'op' => '=', 'value' => 'completed'], ], ], ], // Aggregate: avg ['name' => 'reviews', 'aggregate' => 'avg', 'field' => 'rating'], // Aggregate: min / max ['name' => 'orders', 'aggregate' => 'min', 'field' => 'total'], ['name' => 'orders', 'aggregate' => 'max', 'field' => 'total'], ], // ── Sorting ─────────────────────────────────────────────────────────────── 'sort' => [ 'created_at' => 'desc', 'name' => 'asc', "raw:FIELD(status,'active','pending','inactive')" => 'asc', // orderByRaw ], // ── Pagination ──────────────────────────────────────────────────────────── 'page' => 1, 'limit' => 20, ]; $users = Qubuilder::make($filters, User::class)->query()->paginate();
JSON (for HTTP requests: GET, POST or QUERY)
The same payload encoded as JSON query-string parameters (GET) or request body (POST or QUERY):
{
"select": ["id", "name", "email", "status", "created_at"],
"filter": {
"AND": [
{ "field": "status", "op": "=", "value": "active" },
{ "field": "age", "op": ">=", "value": 18 },
{ "field": "score", "op": "!=", "value": 0 },
{ "field": "role", "op": "in", "value": ["admin", "editor"] },
{ "field": "type", "op": "not_in", "value": ["guest", "banned"] },
{ "field": "created_at", "op": "between", "value": ["2024-01-01", "2024-12-31"] },
{ "field": "score", "op": "not_between", "value": [0, 10] },
{ "field": "verified_at", "op": "not_null" },
{ "field": "deleted_at", "op": "null" },
{ "field": "name", "op": "_like_", "value": "john" },
{ "field": "email", "op": "like_", "value": "admin" },
{ "field": "bio", "op": "_like", "value": ".com" },
{ "field": "created_at", "op": "date", "value": "2024-06-15" },
{ "field": "created_at", "op": "year", "value": 2024 },
{ "field": "created_at", "op": "month", "value": 6 },
{ "field": "created_at", "op": "day", "value": 15 },
{ "field": "published_at", "op": "time", "value": "09:00:00" },
{ "field": "settings->notifications", "op": "json_contains", "value": "email" },
{ "field": "settings->flags", "op": "json_not_contains", "value": "beta" },
{ "field": "updated_at", "op": "field|>", "value": "created_at" },
{ "field": "YEAR(created_at) = ?", "op": "raw", "value": [2024] },
{ "field": "orders", "op": "has|>=", "value": 3 },
{
"field": "orders",
"op": "has",
"value": {
"AND": [{ "field": "status", "op": "=", "value": "completed" }]
}
},
{ "field": "invoices", "op": "doesnthave" },
{
"field": "invoices",
"op": "doesnthave",
"value": {
"AND": [{ "field": "status", "op": "=", "value": "cancelled" }]
}
},
{ "field": ["first_name", "last_name", "email"], "op": "any|_like_", "value": "john" },
{
"OR": [
{ "field": "country", "op": "=", "value": "US" },
{ "field": "country", "op": "=", "value": "CA" }
]
}
]
},
"include": [
{ "name": "profile" },
{
"name": "orders",
"select": ["id", "status", "total"],
"filter": {
"AND": [{ "field": "status", "op": "=", "value": "completed" }]
},
"sort": { "created_at": "desc" },
"limit": 5
},
{
"name": "orders",
"include": [
{ "name": "items", "select": ["id", "product_id", "qty"] }
]
},
{ "name": "orders", "aggregate": "count" },
{
"name": "orders",
"aggregate": "sum",
"field": "total",
"filter": {
"AND": [{ "field": "status", "op": "=", "value": "completed" }]
}
},
{ "name": "reviews", "aggregate": "avg", "field": "rating" },
{ "name": "orders", "aggregate": "min", "field": "total" },
{ "name": "orders", "aggregate": "max", "field": "total" }
],
"sort": {
"created_at": "desc",
"name": "asc",
"raw:FIELD(status,'active','pending','inactive')": "asc"
},
"page": 1,
"limit": 20
}
GET request tip: JSON-encode each parameter value individually in the query string — do not encode the whole object as one string.
GET /api/users ?filter={"AND":[{"field":"status","op":"=","value":"active"}]} &sort={"created_at":"desc"} &include=[{"name":"orders","aggregate":"count"}] &page=1 &limit=20
Generated SQL
The payload above produces the following queries.
deleted_atappears in the filter, so->withTrashed()is applied — the automatic soft-delete scope (AND users.deleted_at IS NULL) is removed and the explicit condition from the filter takes its place.
Main query (aggregate include items are injected as SELECT subqueries):
SELECT `id`, `name`, `email`, `status`, `created_at`, (SELECT count(*) FROM `orders` WHERE `orders`.`user_id` = `users`.`id`) AS `orders_count`, (SELECT sum(`total`) FROM `orders` WHERE `orders`.`user_id` = `users`.`id` AND `orders`.`status` = 'completed') AS `orders_sum_total`, (SELECT avg(`rating`) FROM `reviews` WHERE `reviews`.`user_id` = `users`.`id`) AS `reviews_avg_rating`, (SELECT min(`total`) FROM `orders` WHERE `orders`.`user_id` = `users`.`id`) AS `orders_min_total`, (SELECT max(`total`) FROM `orders` WHERE `orders`.`user_id` = `users`.`id`) AS `orders_max_total` FROM `users` WHERE ( `status` = 'active' AND `age` >= 18 AND `score` != 0 AND `role` IN ('admin', 'editor') AND `type` NOT IN ('guest', 'banned') AND `created_at` BETWEEN '2024-01-01' AND '2024-12-31' AND `score` NOT BETWEEN 0 AND 10 AND `verified_at` IS NOT NULL AND `deleted_at` IS NULL AND `name` LIKE '%john%' AND `email` LIKE 'admin%' AND `bio` LIKE '%.com' AND DATE(`created_at`) = '2024-06-15' AND YEAR(`created_at`) = 2024 AND MONTH(`created_at`) = 6 AND DAY(`created_at`) = 15 AND TIME(`published_at`) = '09:00:00' AND JSON_CONTAINS(`settings`, '"email"', '$.notifications') AND NOT JSON_CONTAINS(`settings`, '"beta"', '$.flags') AND `updated_at` > `created_at` AND YEAR(created_at) = 2024 AND (SELECT count(*) FROM `orders` WHERE `orders`.`user_id` = `users`.`id`) >= 3 AND EXISTS ( SELECT * FROM `orders` WHERE `orders`.`user_id` = `users`.`id` AND `orders`.`status` = 'completed' ) AND NOT EXISTS ( SELECT * FROM `invoices` WHERE `invoices`.`user_id` = `users`.`id` ) AND NOT EXISTS ( SELECT * FROM `invoices` WHERE `invoices`.`user_id` = `users`.`id` AND `invoices`.`status` = 'cancelled' ) AND ( `first_name` LIKE '%john%' OR `last_name` LIKE '%john%' OR `email` LIKE '%john%' ) AND ( `country` = 'US' OR `country` = 'CA' ) ) ORDER BY `created_at` DESC, `name` ASC, FIELD(status,'active','pending','inactive') ASC LIMIT 20 OFFSET 0
Eager-load queries (non-aggregate include items run as separate queries):
-- include: profile (basic) SELECT * FROM `profiles` WHERE `profiles`.`user_id` IN (...) -- include: orders (sub-filtered, sorted, limit 5) SELECT `id`, `status`, `total` FROM `orders` WHERE `orders`.`user_id` IN (...) AND `orders`.`status` = 'completed' ORDER BY `created_at` DESC LIMIT 5 -- include: orders → items (nested — two queries) SELECT * FROM `orders` WHERE `orders`.`user_id` IN (...) SELECT `id`, `product_id`, `qty` FROM `items` WHERE `items`.`order_id` IN (...)
API Reference
Static Constructors
| Method | Description |
|---|---|
Qubuilder::make($filters, $model) |
Create from array + model class or builder |
Qubuilder::makeFromArray(array $array, $model) |
Alias for make() |
Qubuilder::makeFromRequest(?Request $req, $model) |
Parse filters from the HTTP request |
Instance Methods
| Method | Returns | Description |
|---|---|---|
->filters(array) |
$this |
Set the filters array |
->model($model) |
$this |
Set the model or builder |
->query() |
Builder |
Build and return the Eloquent query |
->select() |
Select |
The resolved Select object |
->where() |
Where |
The resolved Where object |
->include() |
Includes |
The resolved Includes object |
->sort() |
Sorts |
The resolved Sorts object |
->page() |
int |
Resolved page number |
->limit() |
int |
Resolved per-page limit |
Filters Array Structure
[
'select' => [], // array of column names
'filter' => [], // nested condition structure
'include' => [], // relationships to load
'sort' => [], // ['column' => 'asc|desc']
'group' => [], // array of group-by columns
'page' => 1,
'limit' => 15,
]
Select
'select' => ['id', 'name', 'email']
Generates ->select(['id', 'name', 'email']). Omitting select defaults to ['*'].
Filter (WHERE Conditions)
Each condition is an associative array with field, op, and optionally value.
Simple Condition
Conditions must always be wrapped in an array or an AND/OR group — a bare associative condition at the top level is not valid.
// Correct — flat array of conditions (all joined with AND) 'filter' => [ ['field' => 'status', 'op' => '=', 'value' => 'active'], ] // Also correct — explicit AND group 'filter' => [ 'AND' => [ ['field' => 'status', 'op' => '=', 'value' => 'active'], ], ]
AND / OR Groups
'filter' => [ 'AND' => [ ['field' => 'status', 'op' => '=', 'value' => 'active'], ['field' => 'age', 'op' => '>=', 'value' => 18], ], ] // WHERE (status = 'active' AND age >= 18)
Groups can be nested to any depth:
'filter' => [ 'AND' => [ ['field' => 'status', 'op' => '=', 'value' => 'active'], [ 'OR' => [ ['field' => 'role', 'op' => '=', 'value' => 'admin'], ['field' => 'role', 'op' => '=', 'value' => 'moderator'], ], ], ], ] // WHERE (status = 'active' AND (role = 'admin' OR role = 'moderator'))
Filter Operators
Comparison
op |
SQL | Note |
|---|---|---|
= |
= value |
Default — used when op is omitted |
!= |
!= value |
Not-equal; standard SQL syntax |
<> |
<> value |
Not-equal; ISO SQL alias for != |
> |
> value |
Greater than |
< |
< value |
Less than |
>= |
>= value |
Greater than or equal to |
<= |
<= value |
Less than or equal to |
['field' => 'score', 'op' => '>=', 'value' => 90]
List
op |
SQL |
|---|---|
in |
WHERE field IN (...) |
not_in |
WHERE field NOT IN (...) |
between |
WHERE field BETWEEN a AND b |
not_between |
WHERE field NOT BETWEEN a AND b |
['field' => 'status', 'op' => 'in', 'value' => ['active', 'pending']] ['field' => 'created_at', 'op' => 'between', 'value' => ['2025-01-01', '2025-12-31']]
Null Checks
op |
SQL | value needed |
|---|---|---|
null |
IS NULL |
No |
not_null |
IS NOT NULL |
No |
['field' => 'verified_at', 'op' => 'not_null']
Soft Deletes: Any filter condition targeting
deleted_at— regardless of operator — automatically applies->withTrashed()globally, so soft-deleted records are included in the result set. This means operators like=,between, ornot_nullondeleted_atall trigger the same behaviour.
Text Search (LIKE)
op |
Pattern | Matches |
|---|---|---|
_like |
%value |
ends with |
like_ |
value% |
starts with |
_like_ |
%value% |
contains |
['field' => 'name', 'op' => '_like_', 'value' => 'john'] // WHERE name LIKE '%john%'
Date & Time
Each operator extracts the specified component and compares with =.
op |
Eloquent method | Example value |
|---|---|---|
date |
whereDate |
'2025-06-15' |
year |
whereYear |
2025 |
month |
whereMonth |
6 |
day |
whereDay |
15 |
time |
whereTime |
'14:30:00' |
['field' => 'created_at', 'op' => 'year', 'value' => 2025] ['field' => 'published_at','op' => 'date', 'value' => '2025-06-15']
JSON Columns
op |
Eloquent method |
|---|---|
json_contains |
whereJsonContains |
json_not_contains |
whereJsonDoesntContain |
Use -> or dot notation to target a nested key.
['field' => 'settings->notifications', 'op' => 'json_contains', 'value' => 'email'] // WHERE JSON_CONTAINS(settings->'$.notifications', '"email"')
Column Comparison
Compare two columns using field|<operator> syntax. Supported operators: =, !=, <>, >, <, >=, <=.
['field' => 'updated_at', 'op' => 'field|>', 'value' => 'created_at'] // WHERE updated_at > created_at
Raw WHERE
field is the raw SQL expression; value is the bindings array.
['field' => 'YEAR(created_at) = ?', 'op' => 'raw', 'value' => [2025]] // whereRaw('YEAR(created_at) = ?', [2025])
Relationship Existence — has / doesnthave
With count comparison (uses has / orHas):
['field' => 'orders', 'op' => 'has|>=', 'value' => 3] // ->has('orders', '>=', 3)
Omitting the sub-operator defaults to =:
['field' => 'orders', 'op' => 'has', 'value' => 1] // ->has('orders', '=', 1)
With sub-filters (uses whereHas):
[
'field' => 'orders',
'op' => 'has',
'value' => [
'AND' => [['field' => 'status', 'op' => '=', 'value' => 'completed']],
],
]
// ->whereHas('orders', fn($q) => $q->where('status', 'completed'))
Does not have:
['field' => 'orders', 'op' => 'doesnthave'] // ->doesntHave('orders') [ 'field' => 'orders', 'op' => 'doesnthave', 'value' => ['AND' => [['field' => 'status', 'op' => '=', 'value' => 'cancelled']]], ] // ->whereDoesntHave('orders', fn($q) => $q->where('status', 'cancelled'))
Nested relations use a dotted path. The package compiles it one segment at a time, so orders.items becomes orders has { items has {...} }: one EXISTS per segment, the sub-filter and any count on the last one.
[
'field' => 'orders.items',
'op' => 'has',
'value' => ['AND' => [['field' => 'qty', 'op' => '>', 'value' => 5]]],
]
// ->whereHas('orders', fn($q) => $q->whereHas('items', fn($q) => $q->where('qty', '>', 5)))
['field' => 'orders.items', 'op' => 'doesnthave']
// ->whereDoesntHave('orders', fn($q) => $q->has('items'))
// users with no order that has an item (an order without items still qualifies)
A dotted path may cross a polymorphic relation (commentable.author). See Polymorphic Relations (MorphTo) for how the morph types are chosen.
Joined relations. On a relation whose query joins another table (BelongsToMany, HasManyThrough, HasOneThrough), the columns of a has / doesnthave sub-filter are qualified for you, with the same rule as joined includes: id, created_at and updated_at mean the related model's, and a column that only the pivot or intermediate table has is read from there.
['field' => 'roles', 'op' => 'has', 'value' => ['AND' => [['field' => 'id', 'op' => '=', 'value' => 3]]]] // ... exists (select * from `roles` inner join `role_user` ... and `roles`.`id` = ?) // a bare `id` would be ambiguous: `role_user` has its own id
Multi-Column — any / all / none
field accepts an array of columns. Append the per-column operator with |.
op |
Eloquent method |
|---|---|
any|op |
whereAny |
all|op |
whereAll |
none|op |
whereNone |
[
'field' => ['first_name', 'last_name', 'email'],
'op' => 'any|_like_',
'value' => 'john',
]
// ->whereAny(['first_name','last_name','email'], 'like', '%john%')
Sort
Key-value pairs of column => direction. Multiple entries are applied in order.
'sort' => ['created_at' => 'desc', 'name' => 'asc'] // ORDER BY created_at DESC, name ASC
Direction is validated — any value other than asc or desc defaults to asc.
Raw Sort Expression
Prefix the column key with raw: to use orderByRaw. The resolved direction is appended.
'sort' => ["raw:FIELD(status,'active','pending','inactive')" => 'asc'] // ORDER BY FIELD(status,'active','pending','inactive') ASC
Group By
Array of column names passed to ->groupBy().
Rule: every column in
selectmust either appear ingroupor be an aggregate expression. If a column is ingroupbut not inselect, it won't appear in the result set — SQL will still accept it, but it is usually a mistake.
// Distinct status / role combinations, sorted alphabetically $filters = [ 'select' => ['status', 'role'], 'group' => ['status', 'role'], 'sort' => ['status' => 'asc', 'role' => 'asc'], ]; $results = Qubuilder::make($filters, User::class)->query()->get();
Generated SQL:
SELECT `status`, `role` FROM `users` GROUP BY `status`, `role` ORDER BY `status` ASC, `role` ASC
Combined with aggregate includes the sub-query columns are independent of the main select/group, so you can mix freely:
// Revenue summary per status $filters = [ 'select' => ['status'], 'group' => ['status'], 'include' => [ ['name' => 'orders', 'aggregate' => 'count'], ['name' => 'orders', 'aggregate' => 'sum', 'field' => 'total'], ], 'sort' => ['status' => 'asc'], ];
Generated SQL:
SELECT `status`, (SELECT count(*) FROM `orders` WHERE `orders`.`user_id` = `users`.`id`) AS `orders_count`, (SELECT sum(`total`) FROM `orders` WHERE `orders`.`user_id` = `users`.`id`) AS `orders_sum_total` FROM `users` GROUP BY `status` ORDER BY `status` ASC
Include (Eager Loading)
Each include item is an array with a required name key (the Eloquent relation method name) and optional sub-filter keys.
Basic
'include' => [ ['name' => 'profile'], ['name' => 'roles'], ]
With Sub-filters
All top-level filter keys (select, group, filter, include, sort, page, limit) work inside include items to scope the loaded relationship.
'include' => [ [ 'name' => 'orders', 'select' => ['status', 'total'], 'filter' => [ 'AND' => [['field' => 'status', 'op' => '=', 'value' => 'completed']], ], 'group' => ['status'], 'sort' => ['total' => 'desc'], 'limit' => 5, ], ]
Joined relations. When the relation's query joins another table (BelongsToMany, HasManyThrough, HasOneThrough), bare column names in the include's select, sort, group and filter are qualified for you:
- A
BelongsToManypivot key orwithPivot()column goes to the pivot table. - A column the related table has goes to the related table, also when the pivot or intermediate table has one with the same name. So
id,created_atandupdated_atare never ambiguous and mean the related model's. - A column only the pivot or intermediate table has goes to that table: an undeclared pivot column, or an intermediate column such as
statusonordersunder auser -> orders -> itemsthrough relation.
Columns are read from the database schema, once per table and connection. An omitted select means the related table's columns only. Names you already qualify (roles.id), raw: sorts and raw conditions are sent as they are.
// users belongsToMany roles through role_user, withPivot('level') 'include' => [ ['name' => 'roles', 'select' => ['id', 'name']], // SELECT `roles`.`id`, `roles`.`name`, ...pivot columns... FROM `roles` INNER JOIN `role_user` ... ['name' => 'roles', 'select' => ['id', 'level']], // `level` is read from `role_user`; $user->roles[0]->level and ->pivot->level both work ]
Nested Includes
'include' => [ [ 'name' => 'orders', 'include' => [ ['name' => 'items', 'select' => ['id', 'product_id', 'qty']], ], ], ]
Aggregate Includes
Set aggregate to compute a value instead of loading records. For avg, sum, min, max you must also set field. An aggregate's filter on a joined relation is qualified like any other joined include.
aggregate |
Result attribute | field |
|---|---|---|
count |
{relation}_count |
Not required |
avg |
{relation}_avg_{field} |
Required |
sum |
{relation}_sum_{field} |
Required |
min |
{relation}_min_{field} |
Required |
max |
{relation}_max_{field} |
Required |
'include' => [ ['name' => 'orders', 'aggregate' => 'count'], // $user->orders_count ['name' => 'orders', 'aggregate' => 'sum', 'field' => 'total'], // $user->orders_sum_total ['name' => 'reviews', 'aggregate' => 'avg', 'field' => 'rating'], // $user->reviews_avg_rating ['name' => 'orders', 'aggregate' => 'min', 'field' => 'total'], // $user->orders_min_total ['name' => 'orders', 'aggregate' => 'max', 'field' => 'total'], // $user->orders_max_total ]
Aggregate includes also accept a filter key to scope the aggregation:
[
'name' => 'orders',
'aggregate' => 'sum',
'field' => 'total',
'filter' => [
'AND' => [['field' => 'status', 'op' => '=', 'value' => 'completed']],
],
]
// withSum(['orders' => fn($q) => $q->where('status','completed')], 'total')
Polymorphic Relations (MorphTo)
If the relation is a MorphTo and the model defines a public {relation}Map() method, the package uses morphWith() to apply selective sub-includes per morph type.
The map keys are morph types — either the alias registered via Relation::morphMap() (e.g. 'post') or the model's fully-qualified class name (e.g. Post::class) when no morph map is configured. The values list the relations that may be eager-loaded for that type.
// On your model: public function commentable(): MorphTo { return $this->morphTo(); } // Must be public — the package calls this method on your model instance. public function commentableMap(): array { return [ 'post' => ['author', 'tags'], 'video' => ['channel'], ]; }
Which types a has / doesnthave searches
A has or doesnthave filter on a MorphTo builds one sub-query per morph type. When two types share a relation name but reach different tables, a sub-filter that fits one type is invalid SQL for the other, and MySQL rejects the whole query ("Unknown column"). So the package chooses the types before it builds the query:
- Candidates. With a
{relation}Map(), only its keys:commentable has {...}searchespostandvideo, even when other models sit inRelation::morphMap(). Without a map, the classes of the morph map that rows actually store in the_typecolumn, found with oneSELECT DISTINCT(the query Laravel's ownwhereHasMorph('*')runs). Without a morph map either, every stored type is searched, as before. - Type condition. An
=orincondition on the relation's type column (bookmarkable_type, or the table-qualifiedbookmarks.bookmarkable_type) in the same AND group, or in an enclosing AND group, narrows the candidates to those types. An OR group of such conditions on that column counts as onein. Values match the morph alias or the class name the way MySQL compares them: case-insensitive, trailing spaces ignored. A value that matches no candidate turns the narrowing off, and a class name from the request is never used to build a sub-query. A type condition that is an OR alternative to thehas(type = X OR has {...}) does not narrow it. - Check. Each type must be able to run the whole sub-filter: every nested
hasnames a relation of the model it runs on (including relations added withresolveRelationUsing()), and every column exists in a table it is read from: the related table, the pivot of aBelongsToMany, or the intermediate table of aHasManyThrough/HasOneThrough. Columns come from the database schema, read once per table and connection, not from$fillable, which usually leaves out real columns. A table whose columns cannot be read keeps the type. - No eligible type. A
hasmatches no rows and adoesnthavematches every row.
Example, with bookmarkable pointing at Article, Course and Album, which all define topics(): on an article it reaches article_topics, which has subject_id; on a course it goes through the course_topic pivot, and on an album it reaches album_topics, and neither has subject_id.
[
'AND' => [
['field' => 'bookmarkable_type', 'op' => '=', 'value' => 'Article'],
[
'field' => 'bookmarkable',
'op' => 'has',
'value' => ['AND' => [[
'field' => 'topics',
'op' => 'has',
'value' => ['AND' => [['field' => 'subject_id', 'op' => 'in', 'value' => [1]]]],
]]],
],
],
]
// Builds a sub-query for Article only. Without the type condition the check drops Course and
// Album as well, because their topics tables have no subject_id column.
Define a {relation}Map() to skip the SELECT DISTINCT, and send the type condition when the client already knows the type: it is the cheapest way to narrow.
A dotted has / doesnthave path through the MorphTo gets the same treatment at every segment. commentable.author has {...} builds a sub-query only for the map types that define author(): post here, since Video has no such relation. Laravel's own nested whereHas('commentable.author', ...) would run author() on every stored type and fail with Call to undefined method Video::author().
[
'field' => 'commentable.author',
'op' => 'has',
'value' => ['AND' => [['field' => 'name', 'op' => '=', 'value' => 'Ann']]],
]
// ->whereHasMorph('commentable', [Post::class], fn($q) => $q->whereHas('author', fn($q) => $q->where('name', 'Ann')))
The MorphTo may sit at any depth of the path (comments.commentable.author). Without a map and without Relation::morphMap(), the types are read from the stored _type column and a type that lacks the relation matches no rows.
HTTP Request Integration
Form Request Classes
The package ships two ready-to-use FormRequest classes that validate every Qubuilder
query parameter before it reaches your controller. They can be used directly or extended
when you need to add your own authorisation logic, extra rules, or custom validation.
GetCollectionRequest
Validates all parameters for paginated list endpoints:
select, filter, include, sort, group (all JSON), page and limit (integers).
Use directly — type-hint it in your controller method:
use Kalimulhaq\Qubuilder\Http\Requests\GetCollectionRequest; use Kalimulhaq\Qubuilder\Support\Facades\Qubuilder; public function index(GetCollectionRequest $request) { return Qubuilder::make($request->filters(), User::class) ->query() ->paginate(); }
Extend — add your own authorize(), extra rules, or override any method:
use Kalimulhaq\Qubuilder\Http\Requests\GetCollectionRequest; class ListUsersRequest extends GetCollectionRequest { public function authorize(): bool { return $this->user()->can('viewAny', User::class); } public function rules(): array { return array_merge(parent::rules(), [ 'search' => ['sometimes', 'string', 'max:100'], ]); } }
The built-in validation rules are available individually if you need to reuse them in a fully custom request class:
| Parameter | Rule class |
|---|---|
select, group |
ValidateStringArray |
filter |
ValidateFilter |
include |
ValidateInclude |
sort |
ValidateSort |
use Kalimulhaq\Qubuilder\Rules\ValidateFilter; use Kalimulhaq\Qubuilder\Support\Helper; public function rules(): array { return [ Helper::param('filter') => ['sometimes', new ValidateFilter], // ... your own rules ]; }
GetResourceRequest
Extends GetCollectionRequest but restricts validation to select and include only —
suitable for single-record endpoints where pagination, filtering, and sorting don't apply.
Use directly:
use Kalimulhaq\Qubuilder\Http\Requests\GetResourceRequest; use Kalimulhaq\Qubuilder\Support\Facades\Qubuilder; public function show(GetResourceRequest $request, int $id) { return Qubuilder::make($request->filters(), User::class) ->query() ->findOrFail($id); }
Extend — same pattern as GetCollectionRequest.
Both classes expose a ->filters() method that returns the normalised array, ready to
pass directly to Qubuilder::make().
use Kalimulhaq\Qubuilder\Support\Facades\Qubuilder; public function index(ListUsersRequest $request) { $builder = User::query()->where('tenant_id', auth()->user()->tenant_id); return Qubuilder::make($request->filters(), $builder) ->query() ->paginate(); }
The QUERY Method
RFC 10008 (June 2026) defines QUERY: a request method that is safe and idempotent like GET, cacheable (per the RFC, the body is part of the cache key), and carries the query in the request body like POST. That makes it the better fit for the list requests Qubuilder parses: a large filter does not have to fit in the URL, and a client can retry the request without side effects.
Qubuilder needs no setup for it. It reads parameters with $request->input(), so GetCollectionRequest, GetResourceRequest and Qubuilder::makeFromRequest() work the same as with POST.
QUERY /api/users Content-Type: application/json Accept: application/json { "filter": {"AND": [{"field": "status", "op": "=", "value": "active"}]}, "sort": {"created_at": "desc"}, "limit": 20 }
Register the route with Route::match:
use Illuminate\Support\Facades\Route; Route::match(['GET', 'POST', 'QUERY'], '/api/users', [UserController::class, 'index']);
Things to know:
- Register the route yourself.
Route::get(),Route::post()andRoute::any()do not includeQUERY. - Send a JSON body (
Content-Type: application/json). It works on every supported Laravel version. - A form-encoded body needs Symfony 7.4 or newer. A body sent as
application/x-www-form-urlencodedis only parsed by Symfony 7.4 and newer: Laravel 13, or Laravel 11 and 12 with an up-to-datecomposer.lock. Multipart bodies and a missingContent-Typeare not supported. - Query-string parameters also work, the same as with GET.
- Support is still limited. Clients, proxies, load balancers and web servers must pass the method through. PHP's built-in server, which
php artisan serveuses, answers501 Not Implemented(checked on PHP 8.3), so try it behind a real web server. - CSRF: Laravel treats
QUERYas a state-changing method, so a route behind the CSRF middleware (web routes) needs a token. API routes are not affected.
Testing
composer test
composer test-coverage # generates HTML coverage report in /coverage
Code Quality
composer analyse # PHPStan (Larastan) static analysis composer format # apply Laravel Pint code style composer lint # check code style without modifying files
Changelog
Please see CHANGELOG for recent changes.
Contributing
Please see CONTRIBUTING for details.
Security
If you discover a security issue please email kalim.dir@gmail.com rather than using the public issue tracker.
Credits
License
The MIT License (MIT). Please see License File for more information.
Support
If you find this package useful, consider supporting me on Ko-fi!
