Reusable query builder helpers for Eloquent APIs on Laravel 10 through 13.
ghostcompiler/laravel-querybuilder is a Laravel package for reusable query builder helpers for eloquent apis on laravel 10 through 13..
It currently has 17 GitHub stars and 2.187 downloads on Packagist (latest version v1.0.5).
Install it with composer require ghostcompiler/laravel-querybuilder.
Discover more Laravel packages by ghostcompiler
or browse all Laravel packages to compare alternatives.
Last updated
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.
Laravel Query Builder helps API endpoints safely accept request query parameters for filtering, sorting, relation includes, sparse fieldsets, pagination, and table-style responses.
The package is designed around explicit allow lists. A request cannot filter, sort, include, or select fields unless your model, schema, or fluent query definition permits it.
Use it when you want:
Install from Packagist:
composer require ghostcompiler/laravel-querybuilder
Laravel package discovery registers the service provider automatically.
To publish the legacy config filename:
php artisan vendor:publish --tag=query-builder-config
To publish the modern config filename:
php artisan vendor:publish --tag=querybuilder-config
Both config files contain the same defaults. The package reads both query-builder and querybuilder config keys for compatibility.
If you are developing the package locally inside another Laravel app:
{
"repositories": [
{
"type": "path",
"url": "/absolute/path/to/laravel-querybuilder",
"options": {
"symlink": true
}
}
],
"require": {
"ghostcompiler/laravel-querybuilder": "*"
}
}
Then run:
composer update ghostcompiler/laravel-querybuilder
The fluent API is the recommended interface for new code.
use GhostCompiler\LaravelQueryBuilder\Query;
Route::get('/users', function () {
return Query::for(User::class)
->tenantScoped()
->schema(UserSchema::class)
->allowedIncludes()
->allowedFilters()
->allowedSorts()
->paginate();
});
Example request:
/users?filter[name]=john&include=roles.permissions&sort=-created_at&fields[users]=id,name,email&page[number]=1&page[size]=15
The fluent API runs in strict mode for its runtime definition. Unknown filters, sorts, includes, and disallowed field requests throw typed query-builder exceptions.
Schemas centralize the public query contract for a model.
use GhostCompiler\LaravelQueryBuilder\QuerySchema;
class UserSchema extends QuerySchema
{
public function filters(): array
{
return ['name', 'email'];
}
public function sorts(): array
{
return ['created_at', 'name'];
}
public function includes(): array
{
return ['roles.permissions', 'profile'];
}
public function fields(): array
{
return ['id', 'name', 'email'];
}
}
Use the schema:
Query::for(User::class)
->schema(UserSchema::class)
->paginate();
Schema instances are cached by class name during the request lifecycle.
You can define filters in a schema:
use GhostCompiler\LaravelQueryBuilder\Filters\AllowedFilter;
class UserSchema extends QuerySchema
{
public function filters(): array
{
return [
'email',
AllowedFilter::exact('status'),
AllowedFilter::partial('name'),
AllowedFilter::scope('active'),
AllowedFilter::custom('high_score', HighScoreFilter::class),
];
}
}
Or define filters directly:
Query::for(User::class)
->allowedFilters([
'email',
AllowedFilter::partial('name'),
'active' => ActiveUsersFilter::class,
])
->get();
Supported filter styles:
AllowedFilter::exact('status')AllowedFilter::partial('name')AllowedFilter::scope('active')AllowedFilter::custom('active', ActiveUsersFilter::class)'email''name' => 'partial''active' => ActiveUsersFilter::classQuery examples:
/users?filter[email][email protected]
/users?filter[name]=ali
/users?filter[active]=true
Create a filter class:
use GhostCompiler\LaravelQueryBuilder\Contracts\Filter;
use Illuminate\Database\Eloquent\Builder;
class ActiveUsersFilter implements Filter
{
public function apply(Builder $query, mixed $value)
{
return $query->where('active', filter_var($value, FILTER_VALIDATE_BOOL));
}
}
Register it:
Query::for(User::class)
->allowedFilters([
'active' => ActiveUsersFilter::class,
])
->get();
Custom filters are trusted code. Avoid raw SQL with unsanitized request values.
Schema example:
public function sorts(): array
{
return ['name', 'created_at'];
}
Direct example:
Query::for(User::class)
->allowedSorts(['name', 'created_at'])
->get();
Query examples:
/users?sort=name
/users?sort=-created_at
/users?sort=name,-created_at
The package rejects non-allow-listed sort fields with InvalidSortException in strict mode.
Schema example:
public function includes(): array
{
return ['profile', 'roles.permissions'];
}
Direct example:
Query::for(User::class)
->allowedIncludes(['profile', 'roles.permissions'])
->get();
Query example:
/users?include=profile,roles.permissions
Includes are validated against:
viewRelation gate policy checksIf a viewRelation gate ability is defined, the package checks it before eager loading a requested include:
use Illuminate\Support\Facades\Gate;
Gate::define('viewRelation', function ($user, $model, string $relation) {
return $relation !== 'roles.permissions' || $user->can('viewPermissions');
});
If the gate denies the relation:
UnauthorizedRelationExceptionIf no viewRelation ability exists, the package assumes route/controller/model authorization is handled by the application.
Schema example:
public function fields(): array
{
return ['id', 'name', 'email'];
}
Request example:
/users?fields[users]=id,name,email
Sparse fieldsets are validated against allowed fields and masked columns. The model primary key is automatically kept when needed.
Configure sensitive columns:
'masked_columns' => [
'users' => ['password', 'remember_token'],
'oauth_clients' => ['secret'],
],
When mask_sensitive_columns is enabled, matching attributes are hidden before serialization, including loaded relations.
Masked columns are also removed from selectable sparse fieldsets.
Tenant scoping is enabled by default for the fluent API if the model table has the configured tenant column.
Query::for(User::class)
->tenantScoped()
->schema(UserSchema::class)
->get();
This applies:
where('tenant_id', auth()->user()->tenant_id)
Disable it for a trusted system query:
Query::for(User::class)
->tenantScoped(false)
->schema(UserSchema::class)
->get();
Use a custom tenant column:
Query::for(User::class)
->tenantScoped(true, 'account_id')
->schema(UserSchema::class)
->get();
Or configure it globally:
'tenant_column' => 'account_id',
The fluent API normalizes JSON:API-style request parameters:
filter[name]=john
include=roles.permissions
sort=-created_at
fields[users]=id,name,email
page[number]=1
page[size]=15
Equivalent normalized structure:
[
'filters' => ['name' => 'john'],
'with' => ['roles.permissions'],
'sort_by' => ['created_at'],
'sort_dir' => ['desc'],
'fields' => ['users' => ['id', 'name', 'email']],
'page' => 1,
'per_page' => 15,
]
Legacy parameter names are still supported:
filters[name]=john
with=roles.permissions
sort_by=created_at
sort_dir=desc
columns=id,name,email
page=1
per_page=15
The fluent paginate() method returns a JSON-friendly array:
$payload = Query::for(User::class)
->schema(UserSchema::class)
->paginate();
Shape:
[
'data' => [...],
'meta' => [
'total' => 50,
'per_page' => 15,
'current_page' => 1,
'last_page' => 4,
],
'links' => [
'first' => '...',
'last' => '...',
'prev' => null,
'next' => '...',
],
]
Override per-page:
Query::for(User::class)
->schema(UserSchema::class)
->paginate(25);
Cache a terminal operation:
Query::for(User::class)
->schema(UserSchema::class)
->cache(60)
->paginate();
The cache key includes the operation, model, request parameters, and runtime definition.
Query::for(User::class)->schema(UserSchema::class)->get();
Query::for(User::class)->schema(UserSchema::class)->first();
Query::for(User::class)->schema(UserSchema::class)->paginate();
Query::for(User::class)->schema(UserSchema::class)->toEloquentBuilder();
get(), first(), and paginate() execute the query. toEloquentBuilder() applies the request rules and returns the underlying Eloquent builder for further trusted server-side work.
Existing projects can keep using the model trait.
use GhostCompiler\LaravelQueryBuilder\Concerns\HasQueryBuilder;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
use HasQueryBuilder;
protected array $searchable = ['name', 'email', 'profile.bio'];
protected array $filterable = ['status', 'roles.name'];
protected array $sortable = ['name', 'created_at', 'profile.city'];
protected array $selectable = ['id', 'name', 'email'];
protected array $allowedRelations = ['profile', 'roles.permissions'];
}
Static builder:
$query = User::QueryBuild($request);
Local scope:
$query = User::query()->queryBuilder($request);
Paginator:
$paginator = User::query()->queryBuilder($request)->paginateQuery();
Table payload:
$payload = User::query()->queryBuilder($request)->paginateTable();
The legacy trait API uses model properties and query-builder.strict_mode to decide whether invalid input throws or is ignored.
Supported model properties:
protected array $searchable = ['name', 'email', 'profile.bio'];
protected array $filterable = ['status', 'roles.name'];
protected array $sortable = ['name', 'created_at'];
protected array $selectable = ['id', 'name', 'email'];
protected array $allowedRelations = ['profile', 'roles.permissions'];
protected array $allowedFilterOperators = [
'status' => ['=', 'in'],
];
protected array $dateFilterable = ['created_at'];
protected array $customFilters = [
'high_score' => 'applyHighScoreFilter',
];
protected string $defaultSortBy = 'created_at';
protected string $defaultSortDir = 'desc';
protected int $defaultPerPage = 15;
protected int $maxPerPage = 100;
protected bool $queryBuilderStrict = true;
Custom model callback:
protected function applyHighScoreFilter($query, mixed $value): void
{
if (filter_var($value, FILTER_VALIDATE_BOOL)) {
$query->where('score', '>=', 90);
}
}
| Parameter | Example | Purpose |
| :--- | :--- | :--- |
| search | ?search=alice | Global search across $searchable. |
| filters | ?filters[status]=active | Allow-listed field filters. |
| sort_by | ?sort_by=created_at | Allow-listed sort fields. |
| sort_dir | ?sort_dir=desc | Sort direction. |
| with | ?with=profile,roles | Allow-listed eager loads. |
| columns | ?columns=id,name,email | Allow-listed selected columns. |
| page | ?page=2 | Page number. |
| per_page | ?per_page=25 | Page size, capped by config/model max. |
| date_from | ?date_from=2025-01-01 | Start date boundary. |
| date_to | ?date_to=2025-12-31 | End date boundary. |
| date_column | ?date_column=created_at | Date column, validated by allow list. |
| trashed | ?trashed=with | Soft-delete handling: with or only. |
Legacy operator syntax:
filters[score][operator]=>=
filters[score][value]=90
Supported operators:
=!=<><=>=likenot_likeinnot_inbetweennullnot_nullRestrict operators per field:
protected array $allowedFilterOperators = [
'status' => ['=', 'in'],
'score' => ['>=', 'between'],
];
Enable headers:
'query_headers' => [
'enabled' => true,
'override_request_values' => true,
],
Example headers:
X-Query-Search: Alice
X-Query-Filter: {"status":"active"}
X-Query-Sort: created_at
X-Query-Sort-Dir: desc
X-Query-With: profile
X-Query-Per-Page: 15
Header values are normalized and validated through the same engine as query-string parameters.
Default config:
return [
'strict_mode' => false,
'deny_unknown_filters' => true,
'deny_unknown_sorts' => true,
'deny_unknown_includes' => true,
'handle_request_automatically' => true,
'tenant_scoping_enabled' => true,
'tenant_column' => 'tenant_id',
'mask_sensitive_columns' => true,
'masked_columns' => [],
'strict_includes' => true,
'policy_aware_includes' => 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,
'max_include_depth' => 3,
'cache_prefix' => 'laravel-querybuilder',
];
Important options:
strict_mode: makes the legacy trait API throw on invalid query input.tenant_scoping_enabled: enables default tenant scoping in the fluent API when the tenant column exists.tenant_column: tenant column used with auth()->user().mask_sensitive_columns: hides configured columns before serialization.masked_columns: table or model keyed sensitive columns.strict_includes: controls whether policy-denied includes throw or are skipped.policy_aware_includes: enables Gate::allows('viewRelation', [$model, $relation]).max_include_depth: caps dotted include depth.max_relation_depth: legacy relation-depth cap for dotted relation paths.cache_prefix: prefix for fluent query cache keys.All package exceptions extend QueryBuilderException.
| Exception | When it is used |
| :--- | :--- |
| InvalidFilterException | Unknown filter, unsupported operator, invalid filter shape, or disallowed filter. |
| InvalidIncludeException | Unknown, disallowed, or missing relation include. |
| InvalidSortException | Unknown or disallowed sort field/direction. |
| UnauthorizedRelationException | viewRelation gate denies an include in strict include mode. |
| IncludeDepthExceededException | Requested include exceeds configured depth. |
| InvalidQueryBuilderQuery | General invalid query-builder input. |
Read validation errors:
try {
Query::for(User::class)->schema(UserSchema::class)->get();
} catch (InvalidQueryBuilderQuery $exception) {
return response()->json([
'errors' => $exception->errors(),
], 422);
}
The package is secure-by-default for query shaping in the fluent API:
The package does not replace:
Custom filters are application code. Keep them parameterized and avoid unsafe raw SQL.
max_include_depth low for public APIs.The full clickable method list is also available in FUNCTIONS.md.
Query::for()Create a fluent query builder for a model class or an existing Eloquent builder.
Query::for(User::class);
Query::for(User::query()->where('active', true));
Query::extend()Register or read extension values by contract name.
Query::extend(Filter::class, ActiveUsersFilter::class);
$extensions = Query::extend(Filter::class);
schema()Attach a QuerySchema class or instance.
Query::for(User::class)->schema(UserSchema::class);
allowedFilters()Override schema filters directly.
Query::for(User::class)->allowedFilters(['email', AllowedFilter::partial('name')]);
allowedSorts()Override schema sorts directly.
Query::for(User::class)->allowedSorts(['name', 'created_at']);
allowedIncludes()Override schema includes directly.
Query::for(User::class)->allowedIncludes(['profile', 'roles.permissions']);
allowedFields()Override schema sparse fieldset columns directly.
Query::for(User::class)->allowedFields(['id', 'name', 'email']);
tenantScoped()Enable, disable, or customize tenant isolation.
Query::for(User::class)->tenantScoped();
Query::for(User::class)->tenantScoped(false);
Query::for(User::class)->tenantScoped(true, 'account_id');
request()Provide request input manually.
Query::for(User::class)->request(request());
Query::for(User::class)->request(['filter' => ['name' => 'Alice']]);
cache()Cache a terminal query operation for the given number of seconds.
Query::for(User::class)->schema(UserSchema::class)->cache(60)->paginate();
toEloquentBuilder()Apply request rules and return the underlying Eloquent builder.
$builder = Query::for(User::class)->schema(UserSchema::class)->toEloquentBuilder();
get()Execute and return an Eloquent collection.
$users = Query::for(User::class)->schema(UserSchema::class)->get();
first()Execute and return the first model or null.
$user = Query::for(User::class)->schema(UserSchema::class)->first();
paginate()Execute and return a JSON-friendly pagination array.
$payload = Query::for(User::class)->schema(UserSchema::class)->paginate();
$payload = Query::for(User::class)->schema(UserSchema::class)->paginate(25);
AllowedFilter::exact()Allow exact matching for a filter.
AllowedFilter::exact('status');
AllowedFilter::partial()Allow partial LIKE matching for a filter.
AllowedFilter::partial('name');
AllowedFilter::scope()Call an Eloquent local scope.
AllowedFilter::scope('active');
AllowedFilter::scope('active_users', 'active');
AllowedFilter::custom()Use a custom filter class, instance, callback, or callable.
AllowedFilter::custom('active', ActiveUsersFilter::class);
Filter::apply()Implement a custom filter.
public function apply(Builder $query, mixed $value)
{
return $query->where('active', true);
}
QuerySchema MethodsDefine allowed query surface.
public function filters(): array;
public function sorts(): array;
public function includes(): array;
public function fields(): array;
public function filterOperators(): array;
public function customFilters(): array;
QueryBuild()Legacy static trait entry point.
$query = User::QueryBuild($request);
queryBuilder()Legacy local scope.
$query = User::query()->queryBuilder($request);
paginateQuery()Legacy paginator scope.
$paginator = User::query()->queryBuilder($request)->paginateQuery();
paginateTable()Legacy table response scope.
$payload = User::query()->queryBuilder($request)->paginateTable();
InvalidQueryBuilderQuery::errors()Read validation error details from a thrown query-builder exception.
$errors = $exception->errors();
QueryBuilderEngine MethodsAdvanced service-level entry points used internally by the fluent and trait APIs.
app(QueryBuilderEngine::class)->apply($builder, $request);
app(QueryBuilderEngine::class)->applyWithDefinition($builder, $request, $definition);
app(QueryBuilderEngine::class)->paginate($builder, $request);
app(QueryBuilderEngine::class)->paginateTable($builder, $request);
Run tests:
composer test
Run formatting:
composer format
Run lint checks:
composer lint
Run static analysis:
composer stan
Run the full quality suite:
composer quality
Optional PostgreSQL test runs can be configured through environment variables:
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
Additional package docs:
MIT
This package was developed using ServBay as the local development environment.
ServBay
Mac M4Mac M4