brnrajoriya / laravel-queryflow
QueryFlow: turn any Eloquent model into a safe, filterable, sortable, searchable and paginated REST list endpoint - driven by a per-model whitelist.
Requires
- php: ^8.3
- laravel/framework: ^12.40|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.20
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.5|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
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.