Build Eloquent queries from API request parameters. Filter, sort, include relationships, select fields, and append computed attributes — all from query string parameters.
Building APIs often requires handling complex query parameters for filtering, sorting, and including relationships. Without a proper solution, you end up with:
- Repetitive boilerplate code in every controller
- Inconsistent parameter handling across endpoints
- Security vulnerabilities from unvalidated user input
- Tight coupling between request handling and business logic
Query Wizard solves these problems by providing a clean, declarative API that:
- Automatically parses request parameters
- Validates and whitelists allowed operations
- Applies filters, sorts, includes, fields, and appends to your queries
- Protects against resource exhaustion attacks with built-in limits
- Supports custom filter/sort/include implementations
composer require jackardios/laravel-query-wizardThe package uses Laravel's auto-discovery, so no additional setup is required.
php artisan vendor:publish --provider="Jackardios\QueryWizard\QueryWizardServiceProvider" --tag="config"use App\Models\User;
use Jackardios\QueryWizard\Eloquent\EloquentQueryWizard;
public function index()
{
$users = EloquentQueryWizard::for(User::class)
->allowedFilters('name', 'email', 'status')
->allowedSorts('name', 'created_at')
->allowedIncludes('posts', 'profile')
->get();
return response()->json($users);
}Now your API supports requests like:
GET /users?filter[name]=John&filter[status]=active&sort=-created_at&include=posts
- Basic Usage
- Filtering
- Sorting
- Including Relationships
- Selecting Fields
- Appending Attributes
- Resource Schemas
- ModelQueryWizard
- Security
- Configuration
- Error Handling
- Advanced Usage
- API Reference
- Comparison with spatie/laravel-query-builder
use Jackardios\QueryWizard\Eloquent\EloquentQueryWizard;
// From a model class
$wizard = EloquentQueryWizard::for(User::class);
// From an existing query builder
$wizard = EloquentQueryWizard::for(User::where('active', true));
// From a relation
$wizard = EloquentQueryWizard::for($user->posts());// Get all results
$users = $wizard->get();
// Get first result
$user = $wizard->first();
$user = $wizard->firstOrFail();
// Paginate results
$users = $wizard->paginate(15);
$users = $wizard->simplePaginate(15);
$users = $wizard->cursorPaginate(15);
// Get the underlying query builder
$query = $wizard->toQuery();Configuration methods (allowedFilters, allowedSorts, etc.) must be called before query builder methods (where, orderBy, etc.):
// ✅ Correct: configuration → builder methods → execution
EloquentQueryWizard::for(User::class)
->allowedFilters('name') // configuration
->allowedSorts('created_at') // configuration
->where('active', true) // builder method
->get(); // execution
// ❌ Wrong: throws LogicException
EloquentQueryWizard::for(User::class)
->where('active', true)
->allowedFilters('name'); // LogicException!For base query scopes, pass a pre-configured query to for():
EloquentQueryWizard::for(User::where('active', true))
->allowedFilters('name')
->get();toQuery() and getSubject() expose the live underlying builder. Treat them as the point where wizard configuration is finalized, and do not call allowed*(), default*(), or schema() afterwards.
Filters allow API consumers to narrow down results based on specific criteria.
use Jackardios\QueryWizard\Eloquent\EloquentFilter;
EloquentQueryWizard::for(User::class)
->allowedFilters(
'name', // Exact match (string shorthand)
'email', // Exact match (string shorthand)
EloquentFilter::exact('status'), // Explicit exact filter
EloquentFilter::partial('bio'), // LIKE %value%
)
->get();Request: GET /users?filter[name]=John&filter[bio]=developer
| Type | Factory | Request Example |
|---|---|---|
| Exact | EloquentFilter::exact('status') |
?filter[status]=active |
| Partial | EloquentFilter::partial('name') |
?filter[name]=john (LIKE %john%) |
| Scope | EloquentFilter::scope('popular') |
?filter[popular]=5000 |
| Trashed | EloquentFilter::trashed() |
?filter[trashed]=with|only |
| Null | EloquentFilter::null('deleted_at') |
?filter[deleted_at]=true (IS NULL) |
| Range | EloquentFilter::range('price') |
?filter[price][min]=10&filter[price][max]=100 |
| Date Range | EloquentFilter::dateRange('created_at') |
?filter[created_at][from]=2024-01-01&filter[created_at][to]=2024-12-31 |
| JSON Contains | EloquentFilter::jsonContains('tags') |
?filter[tags]=laravel,php |
| Operator | EloquentFilter::operator('age', FilterOperator::GREATER_THAN) |
?filter[age]=18 (age > 18) |
| Operator (dynamic) | EloquentFilter::operator('price', FilterOperator::DYNAMIC) |
?filter[price]=>=100 (price >= 100) |
| Callback | EloquentFilter::callback('custom', fn($q, $v, $p) => ...) |
?filter[custom]=value |
| Passthrough | EloquentFilter::passthrough('context') |
Captured but not applied |
All filters support fluent modifiers:
EloquentFilter::exact('status')
->alias('state') // URL parameter name: ?filter[state]=...
->default('active') // Default value when not in request
->prepareValueWith(fn($v) => strtolower($v)) // Transform before applying
->when(fn($v) => $v !== 'all') // Skip filter if returns false
->allowStructuredInput() // Accept structured raw input, still validate prepared value
->asBoolean() // Convert 'true'/'1'/'yes' to boolFilter-specific modifiers:
// Range filter
EloquentFilter::range('price')->minKey('from')->maxKey('to')
// Date range filter
EloquentFilter::dateRange('created_at')
->fromKey('start')->toKey('end')
->dateFormat('Y-m-d')
// JSON contains filter
EloquentFilter::jsonContains('tags')->matchAny() // Default: matchAll()
// Null filter
EloquentFilter::null('deleted_at')->withInvertedLogic() // IS NOT NULL
// Scope filter
EloquentFilter::scope('byAuthor')->withModelBinding() // Load model by IDBuilt-in filters validate the shape of their input before prepareValueWith() and apply() run.
exact,partial,operator: scalar or flat list of scalarsscope: single value or flat list without nested arraysnull,trashed: scalar onlyrange,dateRange: array with boundary keys (min/max,from/to) or a flat list with at least two values
Malformed payloads such as ?filter[name][foo][bar]=Alpha now raise InvalidFilterQuery::invalidFormat(...) instead of reaching SQL generation or PHP warnings.
If you intentionally accept structured raw payloads and normalize them in prepareValueWith(), opt in with allowStructuredInput(). The built-in filter still validates the prepared value shape before applying it to the query.
disable_invalid_filter_query_exception only suppresses unknown-filter errors. It does not suppress malformed filter payload format errors.
Filters with dot notation automatically use whereHas:
EloquentFilter::exact('posts.status') // Filters users by their posts' status
// Disable this behavior:
EloquentFilter::exact('posts.status')->withoutRelationConstraint()Allow API consumers to sort results.
use Jackardios\QueryWizard\Eloquent\EloquentSort;
EloquentQueryWizard::for(User::class)
->allowedSorts('name', 'created_at', EloquentSort::field('email'))
->defaultSorts('-created_at') // Applied only when ?sort is absent
->get();Request: ?sort=name (asc), ?sort=-name (desc), ?sort=-created_at,name (multiple)
?sort= is treated as an invalid request and throws InvalidSortQuery.
| Type | Factory | Description |
|---|---|---|
| Field | EloquentSort::field('created_at') |
Sort by column |
| Count | EloquentSort::count('posts') |
Sort by relationship count |
| Relation | EloquentSort::relation('orders', 'total', 'sum') |
Sort by aggregate (min, max, sum, avg, count, exists) |
| Callback | EloquentSort::callback('custom', fn($q, $dir, $p) => ...) |
Custom logic |
Eager load relationships based on request parameters.
use Jackardios\QueryWizard\Eloquent\EloquentInclude;
EloquentQueryWizard::for(User::class)
->allowedIncludes(
'posts', // Relationship (string shorthand)
'postsCount', // Count (auto-detected by suffix)
EloquentInclude::exists('subscription'),
)
->defaultIncludes('profile') // Used only when ?include is absent
->get();Request: ?include=posts,postsCount,subscriptionExists
?include= explicitly disables includes for that request and does not merge defaults.
| Type | Factory | Description |
|---|---|---|
| Relationship | EloquentInclude::relationship('posts') |
Eager load with with() |
| Count | EloquentInclude::count('posts') |
Load count with withCount() |
| Exists | EloquentInclude::exists('posts') |
Check existence with withExists() |
| Callback | EloquentInclude::callback('custom', fn($q, $rel) => ...) |
Custom logic |
Includes ending with "Count" or "Exists" are auto-detected as count/exists includes.
When root sparse fieldsets are applied, explicit or default count / exists includes remain visible in the serialized output. Their request alias stays request-facing only; the runtime attribute key still follows Laravel's default naming (posts_count, posts_exists).
Allow sparse fieldsets (JSON:API compatible).
EloquentQueryWizard::for(User::class)
->allowedFields('id', 'name', 'email', 'posts.id', 'posts.title')
->get();Request: ?fields[user]=id,name&fields[posts]=id,title or ?fields=id,name
?fields= means an explicit empty root fieldset. ?fields[posts]= means an explicit empty fieldset for posts.
If a count / exists include is active, ?fields= still hides normal root columns but keeps the included runtime attribute visible.
Use relation name as the key, not table name:
// Model: Task with createdBy(): BelongsTo<User>
EloquentQueryWizard::for(Task::class)
->allowedIncludes('createdBy')
->allowedFields('id', 'title', 'createdBy.id', 'createdBy.name')
->get();
// ✅ ?fields[createdBy]=id,name
// ❌ ?fields[users]=id,name — won't work// config/query-wizard.php
'optimizations' => [
'relation_select_mode' => 'safe', // 'safe' (recommended) or 'off'
],Safe mode (default): Automatically injects foreign keys for eager loading and protects relation and root accessors/appends by falling back to a full select when needed.
Off mode: No automatic handling — you must include all required FK columns manually.
Append computed model attributes (accessors) to results.
// Model
class User extends Model
{
protected function fullName(): Attribute
{
return Attribute::get(fn() => "{$this->first_name} {$this->last_name}");
}
}
// Query Wizard
EloquentQueryWizard::for(User::class)
->allowedAppends('full_name', 'posts.reading_time')
->defaultAppends('full_name')
->get();Request: ?append=full_name,posts.reading_time
?append= explicitly disables appends for that request and does not merge defaults.
Defaults are applied only when the corresponding parameter is completely absent.
?include=means "include nothing"?append=means "append nothing"?fields=means "show no root fields", except activecount/existsinclude attributes remain visible?fields[relation]=means "show no fields for that relation"?sort=is invalid and throwsInvalidSortQuery
For larger applications, use Resource Schemas to define all query capabilities in one place.
use Jackardios\QueryWizard\Schema\ResourceSchema;
use Jackardios\QueryWizard\Contracts\QueryWizardInterface;
class UserSchema extends ResourceSchema
{
public function model(): string
{
return User::class;
}
public function filters(QueryWizardInterface $wizard): array
{
return ['name', EloquentFilter::exact('status')];
}
public function sorts(QueryWizardInterface $wizard): array
{
return ['name', 'created_at'];
}
public function includes(QueryWizardInterface $wizard): array
{
return ['posts', 'profile', 'postsCount'];
}
public function fields(QueryWizardInterface $wizard): array
{
return ['id', 'name', 'email', 'status'];
}
public function appends(QueryWizardInterface $wizard): array
{
return ['full_name'];
}
public function defaultSorts(QueryWizardInterface $wizard): array
{
return ['-created_at'];
}
public function defaultFilters(QueryWizardInterface $wizard): array
{
return ['status' => 'active']; // Applied when filter is absent
}
}// With EloquentQueryWizard
$users = EloquentQueryWizard::forSchema(UserSchema::class)->get();
// With ModelQueryWizard (same schema!)
$user = User::find(1);
$processed = ModelQueryWizard::for($user)->schema(UserSchema::class)->process();EloquentQueryWizard::forSchema(UserSchema::class)
->disallowedFilters('status') // Remove from schema
->disallowedIncludes('posts')
->allowedAppends('extra') // Add to schema
->get();| Pattern | Meaning |
|---|---|
'*' |
Block everything |
'posts.*' |
Block direct children only |
'posts' |
Block relation and all descendants |
Schema methods receive the wizard instance for conditional logic:
public function includes(QueryWizardInterface $wizard): array
{
$includes = ['posts', 'profile'];
// Count/exists only work with EloquentQueryWizard
if ($wizard instanceof EloquentQueryWizard) {
$includes[] = EloquentInclude::count('posts');
}
return $includes;
}For processing already-loaded model instances. Handles includes, fields, and appends — not filters or sorts.
Call all configuration methods before process(). After the first successful process(), treat the wizard as single-use for that request/configuration and create a new instance for any different parameters or rules.
use Jackardios\QueryWizard\ModelQueryWizard;
$user = User::find(1);
$processed = ModelQueryWizard::for($user)
->allowedIncludes('posts', 'comments')
->allowedFields('id', 'name', 'email')
->allowedAppends('full_name')
->process();| Feature | Behavior |
|---|---|
| Includes | Loads missing with loadMissing() |
| Fields | Hides non-requested with makeHidden() |
| Appends | Adds with append() |
| Filters/Sorts | Ignored |
Built-in protection against resource exhaustion attacks:
| Setting | Default | Description |
|---|---|---|
max_include_depth |
3 | Max nesting (e.g., posts.comments.author = 3) |
max_includes_count |
10 | Max includes per request |
max_filters_count |
20 | Max filters per request |
max_appends_count |
20 | Max appends per request |
max_sorts_count |
5 | Max sorts per request |
Configure in config/query-wizard.php. Set to null to disable.
By default, ScopeFilter passes values as-is. Enable model binding with caution:
EloquentFilter::scope('byAuthor')->withModelBinding()Warning: Model binding resolves by ID without authorization checks. Add checks in your scope if needed.
Key configuration options (config/query-wizard.php):
return [
'parameters' => [
'includes' => 'include', // ?include=posts
'filters' => 'filter', // ?filter[name]=John
'sorts' => 'sort', // ?sort=-created_at
'fields' => 'fields', // ?fields[user]=id,name
'appends' => 'append', // ?append=full_name
],
'count_suffix' => 'Count', // postsCount → count include
'exists_suffix' => 'Exists', // postsExists → exists include
'disable_invalid_filter_query_exception' => false, // Throw on invalid filter
// ... similar for sort, include, field, append
'request_data_source' => 'query_string', // 'query_string' or 'body' (body only, query string ignored)
'apply_filter_default_on_null' => false, // Apply default() when filter value is null/empty
'naming' => [
'convert_parameters_to_snake_case' => false, // ?filter[firstName] → first_name
],
'optimizations' => [
'relation_select_mode' => 'safe', // 'safe' or 'off'
],
'fields' => [
'use_allowed_as_default' => false,
],
'limits' => [
'max_include_depth' => 3,
'max_includes_count' => 10,
'max_filters_count' => 20,
'max_appends_count' => 20,
'max_sorts_count' => 5,
'max_append_depth' => 3,
],
];When fields.use_allowed_as_default is enabled and ?fields is absent, default fields resolve in this order: explicit defaultFields() on the wizard, schema defaultFields(), then the effective allowed root fields. Relation field allow-lists are not promoted into the root SELECT. This only affects default field selection and does not allow arbitrary ?fields[...] requests when allowed fields are not configured. If no allowed fields are configured, the package keeps its normal behavior: root queries still default to all columns, while explicit ?fields[...] requests are validated against the configured allow-list.
getPassthroughFilters() uses the same filter validation, defaults, prepareValueWith(), when(), and max_filters_count enforcement as normal query execution. Unknown filters still honor disable_invalid_filter_query_exception; malformed built-in filter payloads do not.
All exceptions extend InvalidQuery (extends Symfony's HttpException):
| Exception | Description |
|---|---|
InvalidFilterQuery |
Unknown filter |
InvalidSortQuery |
Unknown sort |
InvalidIncludeQuery |
Unknown include |
InvalidFieldQuery |
Unknown field |
InvalidAppendQuery |
Unknown append |
MaxFiltersCountExceeded |
Too many filters |
MaxIncludeDepthExceeded |
Include nesting too deep |
| ... | (similar for other limits) |
// bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (InvalidQuery $e) {
return response()->json([
'error' => class_basename($e),
'message' => $e->getMessage(),
], $e->getStatusCode());
});
})All execution methods apply post-processing (field masking, appends) automatically:
$wizard->get();
$wizard->paginate(15);
$wizard->chunk(100, fn($users) => ...);
$wizard->lazy()->each(fn($user) => ...);For methods not wrapped by wizard (find(), findMany()):
$user = $wizard->toQuery()->find($id);
$wizard->applyPostProcessingTo($user);Fully compatible. QueryParametersManager uses scoped() binding for per-request instances.
See docs/api-reference.md for complete method reference.
| Feature | Query Wizard | Spatie |
|---|---|---|
| Filters | ||
| Exact, Partial, Scope, Trashed, Callback | Yes | Yes |
| Range, Date Range, Null, JSON Contains | Yes | No |
Passthrough, Conditional (when()) |
Yes | No |
Value transformation (prepareValueWith()) |
Yes | No |
| Sorts | ||
| Field, Callback | Yes | Yes |
| Relationship count/aggregate | Yes | No |
| Includes | ||
| Relationship, Count, Exists, Callback | Yes | Yes |
| Default includes | Yes | No |
| Appends | ||
| Appends with nesting | Yes | No |
| Architecture | ||
| Resource Schemas | Yes | No |
disallowed*() methods |
Yes | No |
| ModelQueryWizard | Yes | No |
| Security | ||
| Request limits | Yes | No |
- PHP 8.1+
- Laravel 10, 11, or 12
composer testSee UPGRADE.md for migration guides between versions.
The MIT License (MIT). Please see License File for more information.
- Salavat Salakhutdinov
- Inspired by spatie/laravel-query-builder by Spatie