Search by

kalimulhaq / qubuilder

kalimulhaq

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.

Package info

github.com/kalimulhaq/qubuilder

pkg:composer/kalimulhaq/qubuilder

Statistics

Installs: 1 191

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v1.4.0 2026-10-28 12:24 UTC

README

Qubuilder: a JSON filter request becomes a chained Eloquent query

Qubuilder

Latest Version on Packagist Total Downloads GitHub Stars Tests PHPStan Code Style PHP Laravel License

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 limit value above max is silently clamped to max. Values of 0 or below are also clamped to max (not to 1 or the default), so sending limit=0 returns max records.

allow_select_all (default true): leave enabled to keep the current behaviour where an omitted select returns all columns. Set to false to force clients to request explicit columns — select becomes required at the top level and inside every non-aggregate include, the "*" wildcard is rejected by validation (a table-qualified users.* 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 (default true): leave enabled to allow relation eager-loading. Set to false to disable includes entirely — the include parameter 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_at appears 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, or not_null on deleted_at all 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 select must either appear in group or be an aggregate expression. If a column is in group but not in select, 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 BelongsToMany pivot key or withPivot() 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_at and updated_at are 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 status on orders under a user -> orders -> items through 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:

  1. Candidates. With a {relation}Map(), only its keys: commentable has {...} searches post and video, even when other models sit in Relation::morphMap(). Without a map, the classes of the morph map that rows actually store in the _type column, found with one SELECT DISTINCT (the query Laravel's own whereHasMorph('*') runs). Without a morph map either, every stored type is searched, as before.
  2. Type condition. An = or in condition on the relation's type column (bookmarkable_type, or the table-qualified bookmarks.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 one in. 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 the has (type = X OR has {...}) does not narrow it.
  3. Check. Each type must be able to run the whole sub-filter: every nested has names a relation of the model it runs on (including relations added with resolveRelationUsing()), and every column exists in a table it is read from: the related table, the pivot of a BelongsToMany, or the intermediate table of a HasManyThrough / 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.
  4. No eligible type. A has matches no rows and a doesnthave matches 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() and Route::any() do not include QUERY.
  • 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-urlencoded is only parsed by Symfony 7.4 and newer: Laravel 13, or Laravel 11 and 12 with an up-to-date composer.lock. Multipart bodies and a missing Content-Type are 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 serve uses, answers 501 Not Implemented (checked on PHP 8.3), so try it behind a real web server.
  • CSRF: Laravel treats QUERY as 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!

Support me on Ko-fi