jackardios / laravel-query-wizard
Laravel Query Wizard
Installs: 511
Dependents: 2
Suggesters: 1
Security: 0
Stars: 0
Watchers: 1
Forks: 0
pkg:composer/jackardios/laravel-query-wizard
Requires
- php: ^8.1
- laravel/framework: ^v10.0
Requires (Dev)
- ext-json: *
- laravel/legacy-factories: ^v1.4.0
- orchestra/testbench: ^v8.21.0
- phpunit/phpunit: ^9.5
- roave/security-advisories: dev-latest
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
- Laravel Octane Compatibility
- Security
- Configuration
- Error Handling
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(); // Throws ModelNotFoundException if not found // Paginate results $users = $wizard->paginate(15); $users = $wizard->simplePaginate(15); $users = $wizard->cursorPaginate(15); // Get the underlying query builder (without executing) $query = $wizard->toQuery();
Modifying the Query
Use tap() to add custom query modifications:
EloquentQueryWizard::for(User::class) ->allowedFilters('name') ->tap(function ($query) { $query->where('active', true) ->whereNotNull('email_verified_at'); }) ->get();
You can also call query builder methods directly - they're proxied to the underlying builder:
EloquentQueryWizard::for(User::class) ->where('active', true) ->whereNotNull('email_verified_at') ->allowedFilters('name') ->get();
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
Exact Filter
Matches exact values. Supports arrays for IN queries.
EloquentFilter::exact('status') EloquentFilter::exact('category_id') // With alias (use different name in URL) EloquentFilter::exact('user_id', 'user') // ?filter[user]=5
Request: ?filter[status]=active or ?filter[status]=active,pending (IN query)
Partial Filter
Case-insensitive LIKE search.
EloquentFilter::partial('name') EloquentFilter::partial('description')
Request: ?filter[name]=john matches "John", "Johnny", "john doe"
Scope Filter
Uses model scopes for filtering.
// Model class User extends Model { public function scopePopular($query, $minFollowers = 1000) { return $query->where('followers_count', '>=', $minFollowers); } } // Query Wizard EloquentFilter::scope('popular')
Request: ?filter[popular]=5000
Callback Filter
Custom filtering logic.
EloquentFilter::callback('age_range', function ($query, $value, $property) { [$min, $max] = explode('-', $value); $query->whereBetween('age', [(int) $min, (int) $max]); })
Request: ?filter[age_range]=18-35
Trashed Filter
Filter soft-deleted models.
EloquentFilter::trashed()
Request: ?filter[trashed]=with (include), ?filter[trashed]=only (only trashed), omit or any other value (exclude)
Range Filter
Filter by numeric ranges.
EloquentFilter::range('price') // Custom keys (default: 'min', 'max') EloquentFilter::range('price')->minKey('from')->maxKey('to')
Request: ?filter[price][min]=100&filter[price][max]=500
Date Range Filter
Filter by date ranges with Carbon parsing.
EloquentFilter::dateRange('created_at') // Custom keys (default: 'from', 'to') EloquentFilter::dateRange('created_at')->fromKey('start')->toKey('end') // Custom date format for DateTime objects EloquentFilter::dateRange('created_at')->dateFormat('Y-m-d')
Request: ?filter[created_at][from]=2024-01-01&filter[created_at][to]=2024-12-31
Null Filter
Check for NULL/NOT NULL values.
EloquentFilter::null('deleted_at') // Invert logic (true = NOT NULL) EloquentFilter::null('verified_at')->withInvertedLogic()
Request: ?filter[deleted_at]=true (IS NULL), ?filter[deleted_at]=false (IS NOT NULL)
JSON Contains Filter
Filter JSON columns.
EloquentFilter::jsonContains('meta.tags') // Match any value (OR) instead of all (AND) EloquentFilter::jsonContains('settings.roles')->matchAny()
Request: ?filter[meta.tags]=laravel,php
Passthrough Filter
Capture filter values without applying them to the query. Useful for external API calls or custom processing.
$wizard = EloquentQueryWizard::for(User::class) ->allowedFilters( EloquentFilter::passthrough('external_id'), ); $results = $wizard->get(); // Access passthrough values $passthroughFilters = $wizard->getPassthroughFilters(); // Collection: ['external_id' => 'value']
Filter Options
All filters support these fluent modifiers:
EloquentFilter::exact('status') ->alias('state') // URL parameter name ->default('active') // Default value when not in request ->prepareValueWith(fn($value) => strtolower($value)) // Transform before applying ->when(fn($value) => $value !== 'all') // Conditional: skip if returns false
Conditional Filtering with when()
Skip filter application based on a condition:
// Skip filter if value is 'all' EloquentFilter::exact('status') ->when(fn($value) => $value !== 'all') // Only apply filter for authenticated users EloquentFilter::exact('user_id') ->when(fn($value) => auth()->check()) // Skip empty values EloquentFilter::partial('search') ->when(fn($value) => !empty($value))
Request: ?filter[status]=all → filter is skipped, all results returned
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', // Field sort (string shorthand) 'created_at', // Field sort (string shorthand) EloquentSort::field('email'), // Explicit field sort ) ->get();
Request: ?sort=name (ascending), ?sort=-name (descending), ?sort=-created_at,name (multiple)
Available Sort Types
Field Sort
Sort by a database column.
EloquentSort::field('created_at') EloquentSort::field('created_at', 'date') // Alias: ?sort=-date
Count Sort
Sort by relationship count.
EloquentSort::count('posts') // Sort by posts count EloquentSort::count('comments', 'popularity') // Alias: ?sort=-popularity
Request: ?sort=-posts (most posts first)
Relation Sort
Sort by a related model's aggregate value.
EloquentSort::relation('posts', 'created_at', 'max') // Newest post date EloquentSort::relation('orders', 'total', 'sum') // Total order amount EloquentSort::relation('ratings', 'score', 'avg') // Average rating
Supported aggregates: max, min, sum, avg, count
Request: ?sort=-orders (highest order total first)
Callback Sort
Custom sorting logic.
EloquentSort::callback('popularity', function ($query, $direction, $property) { $query->orderByRaw("(likes_count + comments_count * 2) {$direction}"); })
Default Sorts
EloquentQueryWizard::for(User::class) ->allowedSorts('name', 'created_at') ->defaultSorts('-created_at') // Applied when no sort in request ->get();
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) 'profile', // Relationship (string shorthand) 'postsCount', // Count (auto-detected by suffix) EloquentInclude::relationship('comments'), EloquentInclude::count('followers'), ) ->get();
Request: ?include=posts,profile,postsCount
Available Include Types
Relationship Include
Eager load a relationship with with().
EloquentInclude::relationship('posts') EloquentInclude::relationship('posts.author') // Nested relationships
Count Include
Load relationship counts with withCount().
EloquentInclude::count('posts') EloquentInclude::count('posts', 'postCount') // Custom alias
Includes ending with "Count" (configurable suffix) are auto-detected:
->allowedIncludes('posts', 'postsCount') // postsCount becomes count include
Important: When you allow a relationship include, its count variant is automatically allowed:
->allowedIncludes('posts') // Also allows 'postsCount'
Callback Include
Custom include logic.
EloquentInclude::callback('recent_posts', function ($query, $relation) { $query->with(['posts' => function ($q) { $q->where('created_at', '>', now()->subMonth()) ->orderBy('created_at', 'desc') ->limit(5); }]); })
Default Includes
EloquentQueryWizard::for(User::class) ->allowedIncludes('posts', 'profile', 'settings') ->defaultIncludes('profile') // Always loaded unless overridden ->get();
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
The resource key (user in the example) is derived from the model name in camelCase. You can customize it with schemas.
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') ->get();
Request: ?append=full_name,posts.reading_time
Nested Appends
Append attributes on related models:
->allowedAppends( 'full_name', // Root model 'posts.reading_time', // Related posts 'posts.author.badge', // Deeply nested )
Wildcard Appends
Allow any appends on a relation:
->allowedAppends('posts.*') // Any append on posts
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\Eloquent\EloquentFilter; use Jackardios\QueryWizard\Eloquent\EloquentSort; use Jackardios\QueryWizard\Eloquent\EloquentInclude; use Jackardios\QueryWizard\Contracts\QueryWizardInterface; class UserSchema extends ResourceSchema { public function model(): string { return \App\Models\User::class; } public function type(): string { return 'user'; // For ?fields[user]=id,name } public function filters(QueryWizardInterface $wizard): array { return [ 'name', EloquentFilter::partial('email'), EloquentFilter::exact('status'), EloquentFilter::scope('popular'), EloquentFilter::trashed(), ]; } public function sorts(QueryWizardInterface $wizard): array { return [ 'name', 'created_at', EloquentSort::callback('popularity', function ($query, $direction) { $query->orderBy('followers_count', $direction); }), ]; } public function includes(QueryWizardInterface $wizard): array { return ['posts', 'profile', 'postsCount']; } public function fields(QueryWizardInterface $wizard): array { return ['id', 'name', 'email', 'status', 'created_at']; } public function appends(QueryWizardInterface $wizard): array { return ['full_name', 'avatar_url']; } public function defaultSorts(QueryWizardInterface $wizard): array { return ['-created_at']; } public function defaultIncludes(QueryWizardInterface $wizard): array { return ['profile']; } }
Using Schemas
use Jackardios\QueryWizard\Eloquent\EloquentQueryWizard; $users = EloquentQueryWizard::forSchema(UserSchema::class)->get();
Combining Schemas with Overrides
You can use a schema as a base and override specific settings:
EloquentQueryWizard::forSchema(UserSchema::class) ->disallowedFilters('status') // Remove filter from schema ->disallowedIncludes('posts') // Remove include from schema ->allowedAppends('extra_append') // Add additional append ->get();
Context-Aware Schemas
Schema methods receive the wizard instance, allowing conditional logic:
use Jackardios\QueryWizard\ModelQueryWizard; public function filters(QueryWizardInterface $wizard): array { // No filters for ModelQueryWizard (already-loaded models) if ($wizard instanceof ModelQueryWizard) { return []; } return [ EloquentFilter::exact('status'), EloquentFilter::partial('name'), ]; }
ModelQueryWizard
For processing already-loaded model instances. Handles includes (load missing), fields (hide), and appends only - not filters or sorts.
use Jackardios\QueryWizard\ModelQueryWizard; $user = User::find(1); $processedUser = ModelQueryWizard::for($user) ->allowedIncludes('posts', 'comments') ->allowedFields('id', 'name', 'email') ->allowedAppends('full_name') ->process();
Request: ?include=posts&fields[user]=id,name&append=full_name
With Schema
$processedUser = ModelQueryWizard::for($user) ->schema(UserSchema::class) ->process();
Behavior
- Includes: Loads missing relationships with
loadMissing(), counts withloadCount() - Fields: Hides non-requested fields with
makeHidden() - Appends: Adds computed attributes with
append() - Filters/Sorts: Ignored (model is already loaded)
Laravel Octane Compatibility
Query Wizard is fully compatible with Laravel Octane. The package uses proper scoped bindings and avoids static state that could leak between requests.
Automatic Handling
- QueryParametersManager uses
scoped()binding, ensuring a fresh instance per request - ScopeFilter reflection cache uses
WeakMap, which auto-cleans when model instances are garbage collected
Optional Cache Clearing
For explicit control, you can clear the ScopeFilter reflection cache in Octane's RequestTerminated listener:
// config/octane.php 'listeners' => [ RequestTerminated::class => [ fn() => \Jackardios\QueryWizard\Eloquent\Filters\ScopeFilter::clearReflectionCache(), ], ],
This is optional since WeakMap handles cleanup automatically, but may be useful in high-memory environments.
Security
Request Limits
Query Wizard includes built-in protection against resource exhaustion attacks.
| Setting | Default | Description |
|---|---|---|
max_include_depth |
5 | Max nesting depth (e.g., posts.comments.author = 3) |
max_includes_count |
10 | Max includes per request |
max_filters_count |
15 | Max filters per request |
max_filter_depth |
5 | Max filter nesting depth |
max_sorts_count |
5 | Max sorts per request |
max_append_depth |
3 | Max append nesting depth |
Configure in config/query-wizard.php:
'limits' => [ 'max_include_depth' => 3, // Stricter limit 'max_includes_count' => 5, 'max_filters_count' => 10, 'max_filter_depth' => 3, 'max_sorts_count' => 3, 'max_append_depth' => 2, ],
Set any limit to null to disable it.
ScopeFilter Model Binding
By default, ScopeFilter passes filter values as-is to your scope methods. If your scope has type-hinted model parameters, you can enable automatic model resolution:
EloquentFilter::scope('byAuthor')->withModelBinding()
Security Warning: When enabled, model binding resolves instances by ID using resolveRouteBinding() without authorization checks.
// Scope accepts a User model public function scopeByAuthor($query, User $author) { return $query->where('author_id', $author->id); } // With model binding enabled: // Request: ?filter[by_author]=123 // User with ID 123 is loaded automatically - ensure authorization in scope!
If you enable model binding, add authorization checks in your scope:
public function scopeByAuthor($query, User $author) { abort_unless(auth()->user()->can('view', $author), 403); return $query->where('author_id', $author->id); }
Configuration
Full configuration file (config/query-wizard.php):
return [ /* * Query parameter names used in URLs. */ 'parameters' => [ 'includes' => 'include', // ?include=posts,comments 'filters' => 'filter', // ?filter[name]=John 'sorts' => 'sort', // ?sort=-created_at 'fields' => 'fields', // ?fields[users]=id,name 'appends' => 'append', // ?append=full_name ], /* * Suffix for count includes. * Example: postsCount will load the count of posts relation. */ 'count_suffix' => 'Count', /* * When true, invalid filters/sorts/etc. are silently ignored. * When false (default), appropriate exception is thrown. */ 'disable_invalid_filter_query_exception' => false, 'disable_invalid_sort_query_exception' => false, 'disable_invalid_include_query_exception' => false, 'disable_invalid_field_query_exception' => false, 'disable_invalid_append_query_exception' => false, /* * Where to read query parameters from. * Options: 'query_string', 'body' */ 'request_data_source' => 'query_string', /* * Separator for array values in query string. * Example: ?filter[status]=active,pending */ 'array_value_separator' => ',', /* * Security limits (set to null to disable). */ 'limits' => [ 'max_include_depth' => 5, 'max_includes_count' => 10, 'max_filters_count' => 15, 'max_filter_depth' => 5, 'max_sorts_count' => 5, 'max_append_depth' => 3, ], ];
Error Handling
Exception Types
| Exception | HTTP | Description |
|---|---|---|
InvalidFilterQuery |
422 | Unknown filter in request |
InvalidSortQuery |
422 | Unknown sort in request |
InvalidIncludeQuery |
422 | Unknown include in request |
InvalidFieldQuery |
422 | Unknown field in request |
InvalidAppendQuery |
422 | Unknown append in request |
MaxFiltersCountExceeded |
422 | Filter count exceeds limit |
MaxSortsCountExceeded |
422 | Sort count exceeds limit |
MaxIncludesCountExceeded |
422 | Include count exceeds limit |
MaxIncludeDepthExceeded |
422 | Include nesting exceeds limit |
All exceptions extend InvalidQuery which extends Symfony's HttpException.
Example Handling
use Jackardios\QueryWizard\Exceptions\InvalidQuery; use Jackardios\QueryWizard\Exceptions\QueryLimitExceeded; try { $users = EloquentQueryWizard::for(User::class) ->allowedFilters('name') ->get(); } catch (QueryLimitExceeded $e) { return response()->json([ 'error' => 'Query limit exceeded', 'message' => $e->getMessage(), ], 422); } catch (InvalidQuery $e) { return response()->json([ 'error' => 'Invalid query', 'message' => $e->getMessage(), ], $e->getStatusCode()); }
Global Exception Handler
In Laravel 11+ bootstrap/app.php:
->withExceptions(function (Exceptions $exceptions) { $exceptions->render(function (InvalidQuery $e) { return response()->json([ 'error' => class_basename($e), 'message' => $e->getMessage(), ], $e->getStatusCode()); }); })
Batch Processing Limitations
Methods like chunk(), lazy(), cursor(), chunkById(), etc. are not directly supported for appends. These methods internally call get() on the underlying Builder, bypassing the wizard's append logic.
Why This Happens
// Inside Laravel's Builder::chunk() $results = $this->forPage($page, $count)->get(); // Calls Builder::get(), not Wizard::get() $callback($results); // Results don't have appends applied
Workaround: Manual Append Application
Use toQuery() to get the built query, then manually apply appends:
$wizard = EloquentQueryWizard::for(User::class) ->allowedFilters('status') ->allowedAppends('full_name', 'posts.reading_time'); // Get the built query $query = $wizard->toQuery(); // Process in chunks with manual append application $query->chunk(100, function ($users) use ($wizard) { $wizard->applyAppendsTo($users); foreach ($users as $user) { // Process user with appends applied } });
Workaround: Using cursor() with LazyCollection
$wizard = EloquentQueryWizard::for(User::class) ->allowedAppends('full_name'); $query = $wizard->toQuery(); $query->cursor()->each(function ($user) use ($wizard) { $wizard->applyAppendsTo([$user]); // Process user });
Supported vs Unsupported Methods
| Method | Appends Support | Notes |
|---|---|---|
get() |
✅ Full | Direct support |
first() |
✅ Full | Direct support |
firstOrFail() |
✅ Full | Direct support |
paginate() |
✅ Full | Direct support |
simplePaginate() |
✅ Full | Direct support |
cursorPaginate() |
✅ Full | Direct support |
chunk() |
⚠️ Manual | Use toQuery() + applyAppendsTo() |
chunkById() |
⚠️ Manual | Use toQuery() + applyAppendsTo() |
cursor() |
⚠️ Manual | Use toQuery() + applyAppendsTo() |
lazy() |
⚠️ Manual | Use toQuery() + applyAppendsTo() |
find() |
❌ None | Use where('id', $id)->first() instead |
API Reference
EloquentQueryWizard Methods
Factory Methods
| Method | Description |
|---|---|
for($subject) |
Create from model class, query builder, or relation |
forSchema($schema) |
Create from a ResourceSchema class |
Configuration Methods
| Method | Description |
|---|---|
schema($schema) |
Set ResourceSchema for configuration |
allowedFilters(...$filters) |
Set allowed filters |
disallowedFilters(...$names) |
Remove filters (override schema) |
allowedSorts(...$sorts) |
Set allowed sorts |
disallowedSorts(...$names) |
Remove sorts (override schema) |
defaultSorts(...$sorts) |
Set default sorts |
allowedIncludes(...$includes) |
Set allowed includes |
disallowedIncludes(...$names) |
Remove includes (override schema) |
defaultIncludes(...$names) |
Set default includes |
allowedFields(...$fields) |
Set allowed fields |
disallowedFields(...$names) |
Remove fields (override schema) |
allowedAppends(...$appends) |
Set allowed appends |
disallowedAppends(...$names) |
Remove appends (override schema) |
defaultAppends(...$appends) |
Set default appends |
tap(callable $callback) |
Add query modification callback |
Execution Methods
| Method | Description |
|---|---|
get() |
Execute and return Collection |
first() |
Execute and return first result |
firstOrFail() |
Execute and return first result or throw exception |
paginate($perPage) |
Execute with pagination |
simplePaginate($perPage) |
Execute with simple pagination |
cursorPaginate($perPage) |
Execute with cursor pagination |
toQuery() |
Build and return query builder |
getSubject() |
Get underlying query builder |
applyAppendsTo($results) |
Apply appends to results (for manual batch processing) |
getPassthroughFilters() |
Get passthrough filter values |
Filter Factory Methods (EloquentFilter)
| Method | Description |
|---|---|
exact($property, $alias) |
Exact match filter |
partial($property, $alias) |
LIKE search filter |
scope($scope, $alias) |
Model scope filter |
trashed($alias) |
Soft delete filter |
null($property, $alias) |
NULL check filter |
range($property, $alias) |
Numeric range filter |
dateRange($property, $alias) |
Date range filter |
jsonContains($property, $alias) |
JSON contains filter |
callback($name, $callback, $alias) |
Custom callback filter |
passthrough($name, $alias) |
Passthrough filter |
Sort Factory Methods (EloquentSort)
| Method | Description |
|---|---|
field($property, $alias) |
Column sort |
count($relation, $alias) |
Relationship count sort |
relation($relation, $column, $aggregate, $alias) |
Relationship aggregate sort |
callback($name, $callback, $alias) |
Custom callback sort |
Include Factory Methods (EloquentInclude)
| Method | Description |
|---|---|
relationship($relation, $alias) |
Eager load relationship |
count($relation, $alias) |
Load relationship count |
callback($name, $callback, $alias) |
Custom callback include |
Requirements
- PHP 8.1+
- Laravel 10, 11, or 12
Testing
composer test
License
The MIT License (MIT). Please see License File for more information.