jackardios/laravel-query-wizard is a Laravel package for laravel query wizard.
It currently has 0 GitHub stars and 809 downloads on Packagist (latest version v2.1.3).
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() 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 bool
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')
// 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 arraysnull, trashed: scalar onlyrange, dateRange: array with boundary keys (min/max, from/to) or a flat list with at least two valuesMalformed 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 active count / exists include attributes remain visible?fields[relation]= means "show no fields for that relation"?sort= is invalid and throws InvalidSortQueryFor 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 |
composer test
See UPGRADE.md for migration guides between versions.
The MIT License (MIT). Please see License File for more information.