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.

Maintainers

Package info

github.com/kalimulhaq/qubuilder

pkg:composer/kalimulhaq/qubuilder

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.1.0 2026-04-07 11:58 UTC

This package is auto-updated.

Last update: 2026-04-07 11:59:12 UTC


README

Latest Version on Packagist Total Downloads GitHub Actions

A Laravel package that converts structured JSON filter arrays into Eloquent query builder chains. Pass select, filter, include, sort, group, page, and limit parameters — from an HTTP request or a plain array — and get back a fully-built Builder instance ready to paginate or execute.

Requires: PHP 8.3+ · Laravel 11+

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 [

    /*
    |--------------------------------------------------------------------------
    | 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'
        '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
    ],

];

Quick Start

From an HTTP Request

use Kalimulhaq\Qubuilder\Support\Facades\Qubuilder;

// GET /api/users?filter={"field":"status","op":"=","value":"active"}&sort={"created_at":"desc"}&limit=20
$users = Qubuilder::makeFromRequest(request(), User::class)
    ->query()
    ->paginate();

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

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 Default
= = value Yes — used when op is omitted
!= != value
<> <> value
> > value
< < value
>= >= value
<= <= value
['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: Filtering on deleted_at automatically applies ->withTrashed() so soft-deleted records are included in the result set.

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.

['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)

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

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().

'group' => ['status', 'type']
// GROUP BY status, type

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, filter, include, sort, page, limit) work inside include items to scope the loaded relationship.

'include' => [
    [
        'name'   => 'orders',
        'select' => ['id', 'status', 'total'],
        'filter' => [
            'AND' => [['field' => 'status', 'op' => '=', 'value' => 'completed']],
        ],
        'sort'   => ['created_at' => 'desc'],
        'limit'  => 5,
    ],
]

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.

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
]

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 {relation}Map() method, the package uses morphWith() to apply selective sub-includes per morph type.

// On your model:
public function commentable(): MorphTo
{
    return $this->morphTo();
}

private function commentableMap(): array
{
    return [
        'post'  => ['author', 'tags'],
        'video' => ['channel'],
    ];
}

HTTP Request Integration

Form Request Classes

The package ships two FormRequest base classes you can extend in your controllers.

GetCollectionRequest — validates all parameters for list endpoints:

use Kalimulhaq\Qubuilder\Http\Requests\GetCollectionRequest;

class ListUsersRequest extends GetCollectionRequest {}

Validates: select (JSON), filter (JSON), include (JSON), sort (JSON), page (integer), limit (integer, max from config).

GetResourceRequest — extends GetCollectionRequest, validates only select and include (suitable for single-resource endpoints).

Both expose a ->filters() method returning the parsed array, ready to pass directly to Qubuilder::make().

use Kalimulhaq\Qubuilder\Support\Facades\Qubuilder;

public function index(ListUsersRequest $request)
{
    return User::query()
        ->where('tenant_id', auth()->user()->tenant_id)
        ->tap(fn ($q) => Qubuilder::make($request->filters(), $q)->query())
        ->paginate($request->filters()['limit']);
}

Testing

composer test
composer test-coverage   # generates HTML coverage report in /coverage

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.