ghostcompiler / laravel-querybuilder
Reusable query builder helpers for Eloquent APIs on Laravel 10 through 13.
Package info
github.com/ghostcompiler/laravel-querybuilder
pkg:composer/ghostcompiler/laravel-querybuilder
Requires
- php: ^8.1
- illuminate/database: ^10.0 || ^11.0 || ^12.0 || ^13.0
- illuminate/http: ^10.0 || ^11.0 || ^12.0 || ^13.0
- illuminate/pagination: ^10.0 || ^11.0 || ^12.0 || ^13.0
- illuminate/support: ^10.0 || ^11.0 || ^12.0 || ^13.0
Requires (Dev)
- larastan/larastan: ^3.9
- laravel/pint: ^1.18
- orchestra/testbench: ^8.0 || ^9.0 || ^10.0 || ^11.0
- phpstan/phpstan: ^2.1
README
A Laravel package for API-ready Eloquent query building with searchable fields, nested relation filters, relation sorting, strict mode, custom filters, pagination helpers, and safer public query interfaces.
Overview
Laravel Query Builder is built to help you expose flexible index endpoints without rewriting the same search, filter, sort, pagination, and eager-load logic in every controller.
It gives you:
- one trait for reusable model query handling
- global search on model and nested relation columns
- per-field filters with supported operators
- one-level relation sorting
- eager-load allow lists
- selective column output
- soft-delete handling
- API-ready pagination metadata
- optional strict mode for invalid query parameters
- custom filter callbacks for app-specific logic
- per-field allowed filter operators
- deny-by-default request allow-lists for filters, sorts, includes, and selected columns
- central request sanitization and validation before query clauses are applied
Compatibility
- Laravel 10
- Laravel 11
- Laravel 12
- Laravel 13
- PHP 8.1+
Installation
Install from Packagist
composer require ghostcompiler/laravel-querybuilder
Install from a local package path
If you are developing this package locally and want to use it inside a Laravel app before publishing it to Packagist, add a path repository to the consuming Laravel app's composer.json:
{
"repositories": [
{
"type": "path",
"url": "/absolute/path/to/laravel-querybuilder"
}
],
"require": {
"ghostcompiler/laravel-querybuilder": "*"
}
}
Then run:
composer update ghostcompiler/laravel-querybuilder
If you want live local changes to reflect immediately, enable symlinks:
{
"repositories": [
{
"type": "path",
"url": "/absolute/path/to/laravel-querybuilder",
"options": {
"symlink": true
}
}
]
}
Package Setup
Laravel package discovery is already enabled through Composer, so the service provider is discovered automatically.
If you want to publish the config file:
php artisan vendor:publish --tag=query-builder-config
That publishes:
config/query-builder.php
Config File
The package config supports these defaults:
return [ 'strict_mode' => false, 'handle_request_automatically' => true, 'query_headers' => [ 'enabled' => false, 'override_request_values' => true, 'names' => [ 'search' => ['X-Query-Search'], 'filters' => ['X-Query-Filters', 'X-Query-Filter'], 'sort_by' => ['X-Query-Sort-By', 'X-Query-Sort'], 'sort_dir' => ['X-Query-Sort-Dir'], 'page' => ['X-Query-Page'], 'per_page' => ['X-Query-Per-Page'], 'date_from' => ['X-Query-Date-From'], 'date_to' => ['X-Query-Date-To'], 'date_column' => ['X-Query-Date-Column'], 'columns' => ['X-Query-Columns'], 'with' => ['X-Query-With', 'X-Query-Include', 'X-Query-Includes'], 'trashed' => ['X-Query-Trashed'], ], ], 'response' => [ 'status_key' => 'status', 'status_value' => true, 'message_key' => 'message', ], 'search_like_mode' => 'contains', 'filter_like_mode' => 'contains', 'default_per_page' => 15, 'max_per_page' => 100, 'default_sort_direction' => 'asc', 'min_search_length' => 3, 'max_filter_count' => 15, 'max_filter_value_count' => 100, 'max_relation_depth' => 3, ];
Meaning of the request-handling option:
handle_request_automaticallyWhentrue,paginateQuery()andpaginateTable()can fall back to Laravel's current request automatically if no explicit request was passed and nothing was already remembered fromqueryBuilder($request).query_headers.enabledWhentrue, the package also reads query-builder instructions from configured request headers.query_headers.override_request_valuesWhentrue, matching query-builder headers override URL/request values. Whenfalse, request query values win and headers only fill missing keys.query_headers.namesLets you rename which headers map tosearch,filters,sort_by,with,page, and the other supported query-builder parameters.strict_modeWhentrue, invalid request keys, invalid request shapes, invalid operators, invalid includes, invalid sort fields, and similar request problems throw an exception instead of being silently ignored.response.status_valueControls the default value returned in thestatusfield. You can keep it astrueor change it to a custom value like'success'.response.status_keyLets you rename the response status key if your API uses a different key name.response.message_keyLets you rename the response message key.search_like_modeControls how global search builds theLIKEpattern. Supported values:contains,starts_with,ends_with,exact.filter_like_modeControls howlikeandnot_likefilters build theirLIKEpatterns.max_filter_value_countCaps the number of values allowed ininandnot_instyle filters to avoid oversized query lists.max_relation_depthLimits how deep dotted relation paths can go for request-driven includes, filters, and searchable relation fields.
Step 1: Add the Trait to a Model
<?php namespace App\Models; use GhostCompiler\LaravelQueryBuilder\Concerns\HasQueryBuilder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; class User extends Model { use HasQueryBuilder; use SoftDeletes; protected array $searchable = [ 'name', 'email', 'profile.bio', 'roles.name', 'roles.permissions.name', ]; protected array $sortable = [ 'id', 'name', 'created_at', 'profile.city', ]; protected array $selectable = [ 'id', 'name', 'email', ]; protected array $filterable = [ 'status', 'score', 'created_at', 'roles.name', 'profile.country', 'profile.is_public', ]; protected array $dateFilterable = [ 'created_at', ]; protected array $allowedRelations = [ 'profile', 'roles', 'roles.permissions', 'posts', ]; protected array $allowedFilterOperators = [ 'status' => ['=', 'in'], 'score' => ['=', '>=', 'between'], 'roles.name' => ['='], 'profile.country' => ['='], 'profile.is_public' => ['=', '!=', 'in', 'not_in'], 'is_high_score' => ['='], ]; protected array $customFilters = [ 'is_high_score' => 'applyHighScoreFilter', ]; protected bool $queryBuilderStrict = false; protected int $defaultPerPage = 15; protected int $maxPerPage = 100; protected string $defaultSortBy = 'created_at'; protected string $defaultSortDir = 'desc'; protected function applyHighScoreFilter($query, mixed $value): void { if (filter_var($value, FILTER_VALIDATE_BOOL)) { $query->where('users.score', '>=', 90); } } }
Step 2: Use It in a Controller
<?php namespace App\Http\Controllers; use App\Models\User; use Illuminate\Http\Request; class UserController { public function index(Request $request) { return User::queryBuilder($request)->paginateTable(); } }
Backward-compatible static usage also works:
User::QueryBuild($request)->paginateTable();
But you no longer need to pass it twice.
If you want to add a custom response message once and keep the package response format:
User::queryBuilder($request, [ 'message' => 'Users fetched successfully.', ])->paginateTable();
If you want fully automatic request handling:
User::query()->paginateTable();
That works when handle_request_automatically is enabled in config.
Step 3: Available Query Parameters
These parameters can be passed through:
User::queryBuilder($request)- the current Laravel request if
handle_request_automaticallyis enabled - configured request headers if
query_headers.enabledis enabled
Security note:
searchonly works for fields in$searchablefiltersonly work for fields in$filterableor$customFiltersdate_columnonly works for fields in$dateFilterablewhen that allow-list is defined, otherwise it falls back to$filterablesort_byonly works for fields in$sortablecolumnsonly works for fields in$selectablewithonly works for relations in$allowedRelations- soft-delete state should be controlled through
trashed, not directdeleted_atfilters
If you do not define those allow-lists, the package now denies those request-driven features by default instead of allowing everything.
Step 4: Request Validation and Sanitization
Before the package touches the query, it sanitizes and validates incoming request parameters.
What is validated:
- unsupported top-level query-builder keys are rejected in strict mode
pageandper_pagemust be valid integersfiltersmust be an associative arraywithmust be a string or array of stringssort_by,sort_dir, andcolumnsmust be strings or string arrayssearch,date_from,date_to,date_column, andtrashedmust be scalar values- response keys like
status_keyandmessage_keyare validated before use - large
inandnot_infilter lists are capped to the configured safe limit - dotted relation filters and search fields are capped by
max_relation_depth
This means the package does not blindly trust request()->all() and inject values directly into query methods.
Header-based query overrides
If you want to keep the URL clean, avoid very long query strings, or send richer query-builder instructions from React, you can enable header support:
'query_headers' => [ 'enabled' => true, 'override_request_values' => true, ],
Supported by default:
X-Query-SearchX-Query-FilterorX-Query-FiltersX-Query-SortorX-Query-Sort-ByX-Query-Sort-DirX-Query-PageX-Query-Per-PageX-Query-Date-FromX-Query-Date-ToX-Query-Date-ColumnX-Query-ColumnsX-Query-With,X-Query-Include,X-Query-IncludesX-Query-Trashed
Recommended header payload formats:
- scalar values as plain strings
- list values like
withorcolumnsas comma-separated strings or JSON arrays filtersas a JSON object
Example:
X-Query-Search: admin X-Query-Sort: created_at X-Query-Sort-Dir: desc X-Query-Per-Page: 20 X-Query-Filter: {"status":"active","roles.name":{"operator":"=","value":"Admin"}} X-Query-With: ["profile","roles.permissions"]
Precedence rule:
- if
override_request_values=true, headers win over matching URL/request keys - if
override_request_values=false, URL/request keys win and headers only fill missing values
This also avoids reserved-key collisions because query-builder commands stay at the top level while model data filters stay inside filters[...] or X-Query-Filter.
Strict validation behavior
If strict mode is enabled:
'strict_mode' => true,
the package throws:
GhostCompiler\LaravelQueryBuilder\Exceptions\InvalidQueryBuilderQuery
for invalid request shapes or disallowed query operations.
If strict mode is disabled, invalid values are sanitized or ignored and the query continues safely.
Basic pagination
?page=2
?per_page=25
per_page is always clamped to the configured max_per_page or the model's $maxPerPage value, so oversized requests cannot force unbounded pagination sizes.
Global search
?search=john
This searches across the fields listed in $searchable.
Search conditions are wrapped in their own grouped where (...) clause, so when search is combined with filters the resulting logic stays scoped correctly:
(filters...) AND (search-column-1 OR search-column-2 OR relation-search...)
For large production datasets, especially on related tables, prefer indexed and selective columns in $searchable.
Sorting
?sort_by=name
?sort_by=name,created_at&sort_dir=asc,desc
?sort_by=profile.city&sort_dir=asc
Relation sorting supports one-level relation paths like profile.city.
Basic filters
?filters[status]=active
?filters[score][operator]=>=&filters[score][value]=90
?filters[score][operator]=between&filters[score][value]=50,100
Large in / not_in lists are capped by max_filter_value_count.
Direct filtering on deleted_at is intentionally blocked; use trashed=with or trashed=only instead.
Relation filters
?filters[roles.name]=Admin
?filters[profile.country]=DE
?filters[profile.is_public]=true
Boolean relation filters are normalized automatically when the related model defines boolean casts.
Custom filters
?filters[is_high_score]=1
Date range
?date_from=2025-01-01
?date_to=2025-12-31
?date_column=created_at
If you define $dateFilterable, the date_column value must be in that allow-list. If you do not define $dateFilterable, the package falls back to the model's $filterable allow-list.
Column selection
?columns=id,name,email
The model primary key is automatically kept in the selected output.
Column selection requires an explicit $selectable allow-list.
Eager loading
?with[]=profile
?with[]=roles.permissions
Eager loading requires an explicit $allowedRelations allow-list.
Soft deletes
?trashed=with
?trashed=only
Filter Operators
Supported operators:
=
!=
<
>
<=
>=
like
not_like
in
not_in
between
null
not_null
You can globally support these operators while still restricting per-field usage with $allowedFilterOperators.
Strict Mode
Strict mode helps when you want invalid query parameters to fail loudly instead of being ignored silently.
You can enable it globally in config:
'strict_mode' => true,
Or enable it per model:
protected bool $queryBuilderStrict = true;
When strict mode is enabled, invalid filters, sorts, eager loads, date columns, or similar invalid query inputs throw:
GhostCompiler\LaravelQueryBuilder\Exceptions\InvalidQueryBuilderQuery
You can catch it in your app and return your own API response format.
Example:
use GhostCompiler\LaravelQueryBuilder\Exceptions\InvalidQueryBuilderQuery; public function index(Request $request) { try { return User::queryBuilder($request)->paginateTable(); } catch (InvalidQueryBuilderQuery $exception) { return response()->json([ 'status' => false, 'message' => 'Invalid query parameters.', 'errors' => $exception->errors(), ], 422); } }
Custom Filter Callbacks
Custom filters are useful when a filter does not map cleanly to one database column.
Example:
protected array $customFilters = [ 'is_high_score' => 'applyHighScoreFilter', ]; protected function applyHighScoreFilter($query, mixed $value, string $operator, string $field, array $definition): void { if (filter_var($value, FILTER_VALIDATE_BOOL)) { $query->where('users.score', '>=', 90); } }
This lets you expose readable public API filters while keeping the SQL logic private.
If a custom filter throws an exception, strict mode surfaces it as an InvalidQueryBuilderQuery entry using the filter key, for example filters.is_high_score, together with the underlying failure message.
Per-Field Operator Rules
You can control which operators are allowed on which fields:
protected array $allowedFilterOperators = [ 'status' => ['=', 'in'], 'score' => ['=', '>=', 'between'], 'roles.name' => ['='], ];
This is useful when:
- some fields should never use
like - enum-style fields should only allow exact match
- public APIs should avoid expensive or unsafe query shapes
LIKE Pattern Modes
Both search and like filters support configurable wildcard modes:
'search_like_mode' => 'contains', 'filter_like_mode' => 'contains',
Supported values:
containsstarts_withends_withexact
For large production datasets, starts_with is often safer than contains because leading wildcards can force full table scans.
Response Shape with paginateTable()
The package can return a table-style response structure:
return User::queryBuilder($request)->paginateTable();
Example shape:
{
"status": true,
"data": [],
"pagination": {
"total": 0,
"per_page": 15,
"current_page": 1,
"last_page": 1,
"from": null,
"to": null,
"has_more": false,
"links": {
"first": null,
"last": null,
"prev": null,
"next": null
}
},
"meta": {
"search": null,
"applied_sorts": [],
"applied_filters": []
}
}
If you pass a custom message:
return User::queryBuilder($request, [ 'message' => 'Users fetched successfully.', ])->paginateTable();
Then the payload also includes:
{
"status": true,
"message": "Users fetched successfully."
}
If you want the status value to be a string instead of a boolean, change config:
'response' => [ 'status_key' => 'status', 'status_value' => 'success', 'message_key' => 'message', ],
You can also override response metadata per call:
return User::queryBuilder($request, [ 'status' => 'success', 'message' => 'Users fetched successfully.', ])->paginateTable();
Full Example
public function index(Request $request) { return User::queryBuilder($request)->paginateTable(); }
Example request:
/api/users?search=admin&filters[status]=active&filters[roles.name]=Admin&sort_by=created_at&sort_dir=desc&with[]=profile&page=1&per_page=20
Safe Model Checklist
For production APIs, your model should usually define:
$searchable$filterable$sortable$selectable$allowedRelations$allowedFilterOperators$customFilterswhen needed
This package is now deny-by-default for request-driven filters, includes, sorts, and selected columns unless those allow-lists are explicitly defined.
Security and Usage
This package validates and applies request-driven query instructions safely, but it does not replace application-level authorization or tenant scoping.
Always start from a safe base query
The safest pattern is to scope the base query first, then let the package apply allowed search, filter, sort, and pagination behavior on top of it.
Safe:
public function index(Request $request) { return $request->user() ->videos() ->queryBuilder($request) ->paginateTable(); }
Also safe:
public function index(Request $request) { return Video::query() ->where('account_id', $request->user()->account_id) ->queryBuilder($request) ->paginateTable(); }
Less safe for multi-tenant apps:
public function index(Request $request) { return Video::queryBuilder($request)->paginateTable(); }
If your app needs tenant boundaries, ownership checks, team scoping, or policy-based restrictions, apply those constraints before using the query builder.
Soft deletes must go through trashed
Direct request-driven filtering on deleted_at is intentionally blocked. Use:
?trashed=with?trashed=only
This keeps soft-delete behavior explicit and prevents request parameters from manipulating the soft-delete column directly.
Relation depth is intentionally capped
Request-driven dotted relation paths are limited by max_relation_depth for:
- eager loading
- relation filters
- searchable relation fields
This helps prevent overly deep relation chains from turning into expensive queries.
Standard relation paths and morphMany relation paths are covered by the current test suite for search, filtering, and eager loading. Relation sorting is still intentionally focused on the standard one-level relation types supported by the package.
Index the fields you expose
If a field is used in:
$filterable$sortable$searchablefor prefix or exact search use cases
you should strongly consider indexing it at the database level. The package can validate and build safe queries, but database indexes are still what make those queries fast in production.
Header mode is optional
If you enable query_headers.enabled, the package can read query-builder instructions from headers like X-Query-Search and X-Query-Filter.
That is useful when:
- you want cleaner shareable URLs
- query strings become too long
- a frontend wants to send structured filter payloads without switching to POST
If both headers and request query parameters are present, precedence is controlled by query_headers.override_request_values.
Long-lived workers are supported
The package uses WeakMap-backed registries for remembered request and response metadata, so temporary builder state follows the lifecycle of the underlying query object instead of lingering across long-lived PHP worker processes.
Aliased base queries are supported
The package supports base queries that rename the root table with from('table as alias'). Qualification, filtering, sorting, and soft-delete handling now follow the active base-table reference instead of assuming the raw model table name.
Example:
User::query() ->from('users as members') ->queryBuilder($request) ->paginateTable();
Testing
This package includes:
- Orchestra Testbench
- an in-memory SQLite test database
- fixture models and relations
- morph relation coverage for request-driven search, filters, and eager loading
- CI coverage for Laravel 10 through 13
The package runtime supports PHP 8.1+, while the local static-analysis quality stack is intended to run on PHP 8.2+ because current Larastan releases require that.
Run tests:
composer test
Run the full quality suite:
composer quality
Optional PostgreSQL test runs can also be configured through environment variables in local development:
TEST_DB_CONNECTION=pgsql
TEST_DB_HOST=127.0.0.1
TEST_DB_PORT=5432
TEST_DB_DATABASE=laravel_querybuilder_test
TEST_DB_USERNAME=postgres
TEST_DB_PASSWORD=secret
composer test
The package test harness supports this mode, but you need a reachable PostgreSQL server and a prepared test database on your machine.
Quality and Security
Additional package docs:
License
MIT
