jackardios / laravel-query-wizard
Laravel Query Wizard
Package info
github.com/Jackardios/laravel-query-wizard
pkg:composer/jackardios/laravel-query-wizard
Requires
- php: ^8.2
- laravel/framework: ^12.69.0 || ^13.30.0
Requires (Dev)
- fakerphp/faker: ^1.23
- larastan/larastan: ^3.10
- laravel/pint: 1.30.4
- orchestra/testbench: ^10.0 || ^11.0
- phpunit/phpunit: ^11.5.50 || ^12.5.8
- staudenmeir/belongs-to-through: ^2.17
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-30 04:45:19 UTC
README
Build Eloquent queries from API request parameters. Filter, sort, include relationships, select fields, and append computed attributes — all from query string parameters.
Why Use Query Wizard?
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
Installation
composer require jackardios/laravel-query-wizard
The package uses Laravel's auto-discovery, so no additional setup is required.
Publish Configuration (Optional)
php artisan vendor:publish --provider="Jackardios\QueryWizard\QueryWizardServiceProvider" --tag="config"
Quick Start
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
Table of Contents
- 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
Basic Usage
Creating a Query Wizard
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());
Executing Queries
// 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 Order
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(), getSubject() and build() expose the live underlying builder, as do getQuery() and toBase() called through the wizard when they return the live query. Treat them as the point where wizard configuration is finalized: calling allowed*(), default*(), or schema() afterwards throws LogicException. So does reconfiguring a clone of such a wizard, or of one that received builder calls; create a new wizard instead. Such a wizard also throws LogicException when its request parameters change, and after a build that failed, since the handed-out builder holds part of the failed build.
Builder methods called on the wizard (where(), orderBy(), ...) run after the request's filters and sorts are applied, so an orderBy() through the wizard sorts after the requested sorts. Executing methods change the wizard's builder the way they change an Eloquent builder: first() adds limit 1, find() adds a key condition, cursorPaginate() adds its order columns.
Filtering
Filters allow API consumers to narrow down results based on specific criteria.
Basic Filters
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
Available Filter Types
| Type | Factory | Request Example |
|---|---|---|
| Exact | EloquentFilter::exact('status') |
?filter[status]=active |
| Partial | EloquentFilter::partial('name') |
?filter[name]=john (LIKE %john%; the value is one phrase, commas included) |
| Scope | EloquentFilter::scope('popular') |
?filter[popular]=5000 |
| Trashed | EloquentFilter::trashed() |
?filter[trashed]=with|only|without |
| 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 (ISO 8601; to includes the whole day) |
| JSON Contains | EloquentFilter::jsonContains('tags') |
?filter[tags]=laravel,php |
| Operator | EloquentFilter::operator('age', FilterOperator::GREATER_THAN) |
?filter[age]=18 (age > 18; number or ISO date) |
| Operator (dynamic) | EloquentFilter::operator('price', FilterOperator::DYNAMIC) |
?filter[price]=>=100 (price >= 100), ?filter[created_at]=<=2024-01-31 |
| Callback | EloquentFilter::callback('custom', fn($q, $v, $p) => ...) |
?filter[custom]=value |
| Passthrough | EloquentFilter::passthrough('context') |
Captured but not applied |
Filter Options
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 (repeated calls chain in order) ->when(fn($v) => $v !== 'all') // Skip filter if returns false ->allowStructuredInput() // Accept structured raw input, still validate prepared value ->withoutValueSplitting() // Keep 'a,b' as one string instead of ['a', 'b'] ->asBoolean() // Read true/false/1/0/yes/no/on/off as bool; anything else is a 400
prepareValueWith() and asBoolean() add steps to one chain that runs in the order the methods were called, each
step receiving the previous result; a null result skips the filter. asBoolean() reads a list item by item, so
?filter[is_active]=1,0 on an exact filter matches either value; a callback filter takes a single boolean and rejects a
list.
String values are split by the filters separator (?filter[status]=active,pending → ['active', 'pending']) for every
filter except partial and the LIKE/NOT_LIKE operators, whose value is a search phrase. Use withoutValueSplitting() / withValueSplitting() to change
that per filter; a list sent as ?filter[name][]=a&filter[name][]=b always arrives as an array.
Filter-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') // Format every bound for a column not stored in the database date format ->lenient() // Also accept any date PHP can read ("yesterday", "-1 week") EloquentFilter::dateRange('created_ts')->asUnixTimestamp() // Integer column of Unix timestamps; accepts timestamps too // 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 ID
Filter Values
Built-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 arraysjsonContains: scalar or flat list of scalarsnull,trashed: scalar onlyrange,dateRange: array with only the boundary keys (min/max,from/to) or a flat list of exactly two values; another key (a typo such asform) is rejected
Malformed payloads such as ?filter[name][foo][bar]=Alpha 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.
A blank value is absent: ?filter= applies no filters, and ?filter[name]=, a value of spaces, ?filter[name]=, and a
list of empty items apply no condition (with apply_filter_default_on_null enabled, the filter's default() applies instead). A value that a
filter has to read and cannot is rejected with InvalidFilterValue (400), whose message says what was expected:
| Filter | Accepts |
|---|---|
asBoolean() |
true, false, 1, 0, yes, no, on, off (any letter case) |
null |
the same booleans |
trashed |
with, only, without (true/false for with/without) |
range |
decimal numbers (10, -2.5); no exponents or hex |
dateRange |
a date (2024-01-31) or an ISO 8601 date-time (2024-01-31T10:00:00+03:00, Z, fractions); see below |
operator with >, >=, <, <= (static or after them with DYNAMIC) |
a decimal number or an ISO 8601 date |
partial |
text or numbers (a boolean is rejected) |
scope |
as many values as the scope takes, each one its parameter's type accepts: int/float take numbers, bool takes the booleans above, a union takes any of its types |
Dates (dateRange, and >, >=, <, <= operators) are read in the application timezone; a date-time with an offset is
converted to it, and so is a DateTimeInterface default. A date names the whole day: to=2024-01-31 and
<=2024-01-31 match all of January 31 (< 2024-02-01), and >2024-01-31 starts on February 1. Send + in an offset
as %2B, since an unencoded + in a query string is a space. dateFormat() formats every bound for the column;
dateFormat('U') / asUnixTimestamp() compares whole seconds and is meant for integer columns.
Dynamic operators: >=, <=, >, <, != and <> at the start of the value. An operator without a value is
absent, and an operator inside a list (?filter[price]=>=1,5) is rejected. !=/<> and plain values are compared as
sent.
LIKE: partial filters and the LIKE/NOT_LIKE operators match the value literally; % and _ in the value are
not wildcards. A list matches any of its phrases (NOT_LIKE: none of them). On PostgreSQL the column is compared as
text, so non-text columns work too, and LIKE/NOT_LIKE are case-sensitive even on a citext column. partial lowercases both sides; SQLite's LOWER() only folds ASCII letters.
To keep the old "skip what you can't read" behavior for a boolean filter, use your own preparer instead of
asBoolean():
EloquentFilter::exact('is_active') ->prepareValueWith(fn ($v) => filter_var($v, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE))
disable_invalid_filter_query_exception only suppresses unknown filter names. It never suppresses malformed payloads
or values a filter cannot read.
Relation Filtering
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()
Sorting
Allow API consumers to sort results.
Basic Sorts
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= (and variants such as ?sort=- or ?sort=,) is treated as an invalid request and throws InvalidSortQuery. With disable_invalid_sort_query_exception enabled, every empty variant counts as "no sort requested" and the default sorts apply.
Available Sort Types
| 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 |
A field sort orders by the qualified column (users.total), so it cannot sort by an alias from select()/selectRaw().
Use a callback sort for that: EloquentSort::callback('total', fn ($q, $dir) => $q->orderBy('total', $dir)).
Including Relationships
Eager load relationships based on request parameters.
Basic Includes
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.
Available Include Types
| 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. Count and exists includes take a
single relation (postsCount); a nested relation such as posts.commentsCount throws InvalidArgumentException when
the include is defined. Use a callback include for nested counts.
An include keeps constraints already registered for the same relation (a developer's with(['posts' => fn ...]), a
parent include's select). A callback include that registers its own closure for a relation replaces an existing
closure, as with() does in Laravel; to keep both, read $query->getEagerLoads() and call the previous closure from
yours. Declare attributes a callback include adds with ->withRuntimeAttributes('posts_total') so sparse fieldsets keep
them visible.
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).
Selecting Fields
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.
Under a wildcard (allowedFields('*')), a requested name that is not a column of the table reaches the query and fails
there (QueryException), so list the columns explicitly when clients may send arbitrary names. A root * also allows
every relation fieldset. disallowedFields() rejects names a client requests; it does not hide them from a * request,
which still returns all columns. A name that matches a disallowed field or one of the model's $hidden attributes in
another letter case (NAME for name) is rejected too, since MySQL would return that column under the name as written.
Other names are returned as written. There is no limit on the number of requested fields.
Relation Fields
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
Relation Key Columns
Sparse fieldsets keep the key columns eager loading needs: the wizard adds them to the root and relation selects, so
?fields[posts]=title still matches posts to their users. BelongsTo, HasOne, HasMany, MorphOne, MorphMany and
BelongsToThrough relations are narrowed to their fieldset; other relations, relations whose model has $appends and
relations with requested appends select all columns and hide the fields outside the fieldset, so accessors keep working.
Appending Attributes
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.
Parameter Semantics
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(withdisable_invalid_sort_query_exception, the defaults apply)
Resource Schemas
For larger applications, use Resource Schemas to define all query capabilities in one place.
Creating a Schema
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 } }
Using Schemas
// With EloquentQueryWizard $users = EloquentQueryWizard::forSchema(UserSchema::class)->get(); // With ModelQueryWizard (same schema!) $user = User::find(1); $processed = ModelQueryWizard::for($user)->schema(UserSchema::class)->process();
Schema Overrides
EloquentQueryWizard::forSchema(UserSchema::class) ->disallowedFilters('status') // Remove from schema ->disallowedIncludes('posts') ->allowedAppends('extra') // Add to schema ->get();
Wildcard Support in disallowed*()
| Pattern | Meaning |
|---|---|
'*' |
Block everything |
'posts.*' |
Block direct children only |
'posts' |
Block relation and all descendants |
Context-Aware Schemas
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; }
ModelQueryWizard
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() |
| Relations not requested | Unset from the model (loaded relations not in ?include are removed) |
| Filters/Sorts | Ignored |
Security
Request Limits
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_append_depth |
3 | Max append nesting (e.g., posts.author.full_name = 3) |
max_sorts_count |
5 | Max sorts per request |
Configure in config/query-wizard.php. Set a limit to null to disable it. Any other value that is not a positive
integer (0, '' from an empty environment variable, false, -1) throws InvalidArgumentException instead of
silently disabling the limit, and a limit missing from a published limits array takes the package default.
Limits apply to what the client sends. Developer defaults (defaultSorts(), defaultIncludes(), defaultAppends(),
schema defaults) over a limit throw InvalidArgumentException, since only the developer can fix them.
ScopeFilter Model Binding
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. Because a missing ID and an existing one may produce different results, binding also tells a client which IDs exist.
Values Exposed by Sorting
A cursor encodes the values of the columns the query is ordered by, including columns hidden by a sparse fieldset.
Clients can decode it, so don't sort by columns whose values they must not see. Count and aggregate sorts add their
value to each model (posts_count, orders_max_total); without a root fieldset it is serialized with the model.
Configuration
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 ], '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.
Configuration values are validated when they are read: an invalid limit, separator (a non-empty string of at most 10
characters), parameter name (a non-empty string, or null to turn the parameter off), request_data_source
or boolean option (true/false, or a string such as 'false' or 'off') throws
InvalidArgumentException naming the key. A key missing from the published file takes the
package default. Each build reads the configuration once, so a config()->set() at runtime applies from the next build;
parameter names and separators apply from the next request.
With convert_parameters_to_snake_case enabled, only the names of filters, sorts, includes, fields and appends are
converted. Keys inside a filter value (a range's minKey(), a structured callback payload) are passed as sent, and when
two filter keys convert to the same name (createdAt and created_at), the one already in snake_case wins.
With request_data_source set to body, a JSON request body must be a JSON object; malformed or non-object JSON
throws InvalidRequestBody (400). A body is read as JSON only when the request has a JSON content type.
Error Handling
All exceptions extend InvalidQuery (extends Symfony's HttpException, status 400). Each carries a stable
errorCode and the request parameter it refers to (as configured under parameters, e.g. filter), or null:
| Exception | errorCode |
When |
|---|---|---|
InvalidFilterQuery |
filter_not_allowed |
Unknown filter |
InvalidFilterQuery |
invalid_filter_format |
Malformed filter payload |
InvalidFilterValue |
invalid_filter_value |
A value the filter cannot read (see $reason, $filterName, $filterValue) |
InvalidSortQuery |
sort_not_allowed |
Unknown sort |
InvalidSortQuery |
invalid_sort_format |
Empty or malformed sort |
InvalidIncludeQuery |
include_not_allowed |
Unknown or disallowed include |
InvalidFieldQuery |
field_not_allowed |
Unknown or disallowed field |
InvalidFieldQuery |
invalid_field_format |
Nested lists, a dotted name inside a fieldset, or a name that is not a valid column identifier |
InvalidAppendQuery |
append_not_allowed |
Unknown or disallowed append |
InvalidAppendQuery |
invalid_append_format |
Nested lists in append |
InvalidRequestBody |
invalid_request_body |
Malformed or non-object JSON body in body mode |
MaxFiltersCountExceeded |
max_filters_count_exceeded |
Too many filters |
MaxSortsCountExceeded |
max_sorts_count_exceeded |
Too many sorts |
MaxIncludesCountExceeded |
max_includes_count_exceeded |
Too many includes |
MaxIncludeDepthExceeded |
max_include_depth_exceeded |
Include nesting too deep |
MaxAppendsCountExceeded |
max_appends_count_exceeded |
Too many appends |
MaxAppendDepthExceeded |
max_append_depth_exceeded |
Append nesting too deep |
Configuration mistakes (invalid config values, developer defaults over a limit, a nested relation in a count sort or
count/exists include) throw InvalidArgumentException instead, since they are not the client's fault.
Global Handler
// bootstrap/app.php ->withExceptions(function (Exceptions $exceptions) { $exceptions->render(function (InvalidQuery $e) { return response()->json([ 'error' => $e->errorCode, 'parameter' => $e->parameter, 'message' => $e->getMessage(), ], $e->getStatusCode()); }); })
Advanced Usage
Batch Processing
All execution methods apply post-processing (field masking, appends) automatically:
$wizard->get(); $wizard->paginate(15); $wizard->chunk(100, fn($users) => ...); $wizard->chunkById(100, fn($users) => ...); // also lazyById(), lazyByIdDesc(), chunkByIdDesc(), eachById() $wizard->each(fn($user) => ...); $wizard->chunkMap(fn($user) => ...); $wizard->lazy()->each(fn($user) => ...); $wizard->cursor()->each(fn($user) => ...);
The *ById methods and cursorPaginate() select the key or order columns they need even when a sparse fieldset left
them out, and hide them again in the results. cursor() loads includes (and any other eager loads) for each batch of
1000 models, so up to 1000 models and their relations are in memory at once; without eager loads it streams one model at
a time as before.
Finder methods called on the wizard (find(), findMany(), findOrFail(), findOr(), findSole(), sole(),
firstWhere(), firstOr()) build the query and post-process the models they return. Like on an Eloquent builder, they
narrow the wizard's query (for example find() adds a key condition). Methods that create or return raw values
(firstOrNew(), firstOrCreate(), updateOrCreate(), value(), pluck(), ...) are not post-processed.
Manual Post-Processing
For queries you run on the builder yourself:
$user = $wizard->toQuery()->find($id); $wizard->applyPostProcessingTo($user);
Extending
Custom filters, sorts and includes extend AbstractFilter, AbstractSort or AbstractInclude. These hooks are part of
the supported API:
| Hook | Purpose |
|---|---|
Support\FilterValueParser |
Read request values the way built-in filters do: isBlank(), boolean(), number(), isoDate(); unreadable values throw InvalidFilterValue |
Support\ParsedDate |
Result of the date readers: value (DateTimeImmutable) and dateOnly |
AbstractFilter::supportsBooleanLists() |
Return false when asBoolean() must reject lists |
resolveConstraint(mixed $value): mixed |
For filters using HandlesRelationFiltering: read the value once into what applyOnQuery() receives; null adds no condition, so no whereHas is added |
Contracts\ProvidesRuntimeAttributes |
Includes that add attributes (runtimeAttributes(): list<string>) keep them visible under sparse fieldsets |
rollbackFailedBuild(): void |
Wizard subclasses reset their own state after a build throws (call the parent) |
resolveAppendAccessorModel(string $relationPath): ?Model |
The model whose accessors a wildcard append must name (null = no check) |
QueryWizardConfig::snapshot() |
Configuration fixed at the time of the call |
Classes marked @internal may change in any release.
Laravel Octane
Fully compatible. QueryParametersManager uses scoped() binding for per-request instances.
API Reference
See docs/api-reference.md for complete method reference.
Comparison with spatie/laravel-query-builder
| 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 |
Requirements
- PHP 8.2+ (tested on 8.2–8.5)
- Laravel 12.61.1+ or 13.12.0+
Testing
composer test
Upgrading
See UPGRADE.md for migration guides between versions and CHANGELOG.md for the list of changes.
License
The MIT License (MIT). Please see License File for more information.
Credits
- Salavat Salakhutdinov
- Inspired by spatie/laravel-query-builder by Spatie