jackardios/laravel-query-wizard is a Laravel package for laravel query wizard.
It currently has 0 GitHub stars and 1.061 downloads on Packagist (latest version v3.0.0-rc.6).
Install it with composer require jackardios/laravel-query-wizard.
Discover more Laravel packages by jackardios
or browse all Laravel packages to compare alternatives.
Last updated
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:
Query Wizard solves these problems by providing a clean, declarative API that:
composer require jackardios/laravel-query-wizard
The 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
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(), 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.
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%; 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 |
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
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 as form) is rejectedMalformed 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.
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= (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.
| 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)).
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. 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).
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.
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
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.
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 active count / exists include attributes remain visible?fields[relation]= means "show no fields for that relation"?sort= is invalid and throws InvalidSortQuery (with disable_invalid_sort_query_exception, the defaults apply)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() |
| Relations not requested | Unset from the model (loaded relations not in ?include are removed) |
| 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_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.
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.
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.
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.
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.
// 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());
});
})
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.
For queries you run on the builder yourself:
$user = $wizard->toQuery()->find($id);
$wizard->applyPostProcessingTo($user);
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.
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 |
composer test
See UPGRADE.md for migration guides between versions and CHANGELOG.md for the list of changes.
The MIT License (MIT). Please see License File for more information.