laravel-queryflow maintained by brnrajoriya
QueryFlow for Laravel
QueryFlow turns any Eloquent model into a complete REST list endpoint - pagination, sorting, keyword search, filters, relations, counts and aggregates - from one whitelist on the model.
#[Queryable(
filterable: ['id', 'status', 'author_id', 'views', 'created_at'],
sortable: ['id', 'title', 'views', 'created_at'],
searchable: ['title', 'body', 'author.name'],
includable: ['author', 'comments'],
)]
class Post extends Model
{
use HasQueryFlow;
}
public function index(QueryFlowRequest $request)
{
return Post::flow()->apply($request->queryFlow())->get();
}
GET /posts?keyword=laravel&filter[status]=published&with=author&with_count=comments&order_by=views&per_page=20
GET /posts?return_type=count&group_by=status → [{"status":"draft","count":4}, ...]
Created by Bhaskar Rajoriya. QueryFlow started in 2020 as the
addOperationsInQuery() helper used across production Laravel projects, and is now a tested,
open-source package.
Why QueryFlow
- One declaration per model - every list endpoint in your API behaves the same way.
- Safe by default - only whitelisted columns and relations are accepted; unknown input is a 422, never raw SQL.
- Your constraints stay yours - client filters are wrapped in one group, so
or_wherecan never escape awhere('user_id', $me)you added. - Fast - simple / cursor pagination for big tables,
with_countinstead of loading relations, stable ordering, capped page sizes. - Portable - tested on SQLite, MySQL 8.4 and PostgreSQL 17, with Laravel 12 and 13.
Installation
composer require brnrajoriya/laravel-queryflow
php artisan vendor:publish --tag=queryflow-config # optional
Requires PHP 8.3+ and Laravel 12.40+ or 13.
Declaring what a model exposes
With the attribute:
use BrnRajoriya\QueryFlow\Attributes\Queryable;
use BrnRajoriya\QueryFlow\Concerns\HasQueryFlow;
#[Queryable(filterable: [...], sortable: [...], searchable: [...], includable: [...])]
class Post extends Model
{
use HasQueryFlow; // optional: adds Post::flow() and $post->loadFlow()
}
Or with properties:
class Post extends Model
{
protected array $filterable = ['id', 'status', 'created_at'];
protected array $sortable = ['id', 'title'];
protected array $searchable = ['title', 'author.name'];
protected array $includable = ['author', 'comments'];
}
| List | Used by | Default |
|---|---|---|
filterable |
filter, operations, select, group_by, aggregates |
key + fillable + timestamps, minus $hidden |
sortable |
order_by |
filterable |
searchable |
keyword (relation.column searches a relation) |
none |
includable |
with, with_count, where_has, has, doesnt_have |
none |
Using it
use BrnRajoriya\QueryFlow\QueryFlow;
use BrnRajoriya\QueryFlow\Params;
// From a FormRequest (validation runs before the controller, API doc tools can read the rules)
$result = Post::flow()->apply($request->queryFlow())->get();
// From any array (validates too)
$result = QueryFlow::for(Post::class)->apply($request->query())->get();
// Keep your own constraints - client filters cannot escape them
$result = QueryFlow::for(Post::query()->where('user_id', $request->user()->id))
->apply($request->queryFlow())
->get();
// Relations work too
$result = QueryFlow::for($user->posts())->apply($params)->get();
// Single record: ?with=author&with_count=comments
$post->loadFlow($request->query());
get() returns:
| Request | Result |
|---|---|
| default | LengthAwarePaginator |
pagination=simple |
Paginator (no COUNT(*) query) |
pagination=cursor |
CursorPaginator (fastest for large tables / infinite scroll) |
return_type=count |
int |
return_type=sum|avg|min|max + aggregate_column |
int|float |
any aggregate + group_by |
[['status' => 'draft', 'count' => 4], ...] |
Extend QueryFlowRequest to add your own rules:
class IndexRequest extends QueryFlowRequest
{
public function rules(): array
{
return [...parent::rules(), 'status' => ['sometimes', 'string']];
}
}
Query parameters
| Parameter | Example | Notes |
|---|---|---|
page |
2 |
|
per_page |
25 |
default 25, max 100 (configurable) |
pagination |
paginate | simple | cursor |
cursor uses the cursor parameter |
order_by |
views or views,title |
sortable only; key is added as tie breaker |
order_type |
desc or desc,asc |
one value for all columns, or one per column |
keyword |
laravel |
case-insensitive; % and _ are literal |
filter[col] |
filter[status]=published |
equals; filter[status][]=a&filter[status][]=b = in |
operations[] |
see below | up to 25, nested up to 3 levels |
select |
title,views or select[]=title |
key and needed foreign keys are added automatically |
with |
author,comments or author.company |
includable only |
with_count |
comments |
adds comments_count |
trashed |
with | only |
models using SoftDeletes |
return_type |
data | count | sum | avg | min | max |
|
aggregate_column |
views |
required for sum / avg / min / max |
group_by |
status |
only with an aggregate return_type |
Every name can be changed in config/queryflow.php ('order_by' => 'sort_by') to match an existing frontend.
Operations
// e.g. with qs.stringify(params)
{
operations: [
{ code: 'where', parameters: { column: 'status', operator: '=', value: 'published' } },
{ code: 'where_between', parameters: { column: 'created_at', values: ['2026-01-01', '2026-12-31'] } },
{ code: 'or_group', operations: [ // ... OR (views > 100 AND body IS NULL)
{ code: 'where', parameters: { column: 'views', operator: '>', value: 100 } },
{ code: 'where_null', parameters: { column: 'body' } },
]},
{ code: 'where_has', relation: 'author', parameters: { column: 'name', operator: 'like', value: 'Jo%' } },
],
}
| Parameters | Codes |
|---|---|
column, operator, value |
where, or_where, where_date, where_month, where_day, where_year, where_time |
column, values[] |
where_in, where_not_in, or_where_in, or_where_not_in |
column, values[2] |
where_between, or_where_between, where_not_between, or_where_not_between |
column |
where_null, where_not_null, or_where_null, or_where_not_null |
column_1, operator, column_2 |
where_column, or_where_column |
relation (+ optional column/operator/value) |
where_has, or_where_has, has, doesnt_have |
operations[] |
group, or_group |
Operators: =, !=, <>, <, <=, >, >=, like, not like (configurable).
Existing projects can keep calling addOperationsInQuery($query, $operations).
Security
- Column, relation and operator names are checked against whitelists; values are always bindings.
- Relation names are only resolved after the whitelist check, so a request can never call arbitrary model methods (
with=delete). $hiddencolumns (e.g.password) are never filterable by default.- Limits on page size, operations, nesting depth, relations and selected columns (config).
Performance tips
- Add database indexes for the columns in
filterable/sortableyou actually filter and sort on. - Use
pagination=cursor(orsimple) for large or append-only tables. - Prefer
with_countoverwithwhen the client only shows a number. %keyword%search cannot use a normal index; for large tables consider Laravel Scout / full-text search.
Testing
composer test # SQLite
DB_DRIVER=mysql DB_PORT=3306 composer test
DB_DRIVER=pgsql DB_PORT=5432 DB_USERNAME=postgres composer test
Credits & license
QueryFlow is created and maintained by Bhaskar Rajoriya (LinkedIn).
Released under the MIT License: free to use in personal and commercial projects. Please keep the copyright notice. If QueryFlow helps your project, a mention of "QueryFlow by Bhaskar Rajoriya" or a GitHub star is appreciated - GitHub's "Cite this repository" button uses CITATION.cff.
A ready-to-use API that uses QueryFlow: Laravel API Boilerplate.