{"id":15470952,"url":"https://github.com/jackardios/laravel-query-wizard","last_synced_at":"2026-05-02T13:33:33.152Z","repository":{"id":41059268,"uuid":"400386134","full_name":"Jackardios/laravel-query-wizard","owner":"Jackardios","description":"Easily build Eloquent queries from API requests","archived":false,"fork":false,"pushed_at":"2024-02-29T08:28:19.000Z","size":198,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":3,"default_branch":"master","last_synced_at":"2024-10-19T01:13:35.196Z","etag":null,"topics":["json-api","laravel","php","query-builder"],"latest_commit_sha":null,"homepage":"","language":"PHP","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/Jackardios.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2021-08-27T04:19:24.000Z","updated_at":"2021-10-26T09:47:39.000Z","dependencies_parsed_at":"2024-02-29T09:44:10.790Z","dependency_job_id":null,"html_url":"https://github.com/Jackardios/laravel-query-wizard","commit_stats":{"total_commits":76,"total_committers":1,"mean_commits":76.0,"dds":0.0,"last_synced_commit":"045098d6efea561f13969baadd29fdbf5a5418c6"},"previous_names":[],"tags_count":6,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Jackardios%2Flaravel-query-wizard","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Jackardios%2Flaravel-query-wizard/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Jackardios%2Flaravel-query-wizard/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Jackardios%2Flaravel-query-wizard/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/Jackardios","download_url":"https://codeload.github.com/Jackardios/laravel-query-wizard/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":246034279,"owners_count":20712851,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["json-api","laravel","php","query-builder"],"created_at":"2024-10-02T02:08:03.884Z","updated_at":"2026-05-02T13:33:33.137Z","avatar_url":"https://github.com/Jackardios.png","language":"PHP","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Laravel Query Wizard\n\nBuild Eloquent queries from API request parameters. Filter, sort, include relationships, select fields, and append computed attributes — all from query string parameters.\n\n[![Latest Version on Packagist](https://img.shields.io/packagist/v/jackardios/laravel-query-wizard.svg)](https://packagist.org/packages/jackardios/laravel-query-wizard)\n[![License](https://img.shields.io/packagist/l/jackardios/laravel-query-wizard.svg)](https://packagist.org/packages/jackardios/laravel-query-wizard)\n[![CI](https://github.com/jackardios/laravel-query-wizard/actions/workflows/ci.yml/badge.svg)](https://github.com/jackardios/laravel-query-wizard/actions)\n\n## Why Use Query Wizard?\n\nBuilding APIs often requires handling complex query parameters for filtering, sorting, and including relationships. Without a proper solution, you end up with:\n\n- Repetitive boilerplate code in every controller\n- Inconsistent parameter handling across endpoints\n- Security vulnerabilities from unvalidated user input\n- Tight coupling between request handling and business logic\n\n**Query Wizard solves these problems** by providing a clean, declarative API that:\n\n- Automatically parses request parameters\n- Validates and whitelists allowed operations\n- Applies filters, sorts, includes, fields, and appends to your queries\n- Protects against resource exhaustion attacks with built-in limits\n- Supports custom filter/sort/include implementations\n\n## Installation\n\n```bash\ncomposer require jackardios/laravel-query-wizard\n```\n\nThe package uses Laravel's auto-discovery, so no additional setup is required.\n\n### Publish Configuration (Optional)\n\n```bash\nphp artisan vendor:publish --provider=\"Jackardios\\QueryWizard\\QueryWizardServiceProvider\" --tag=\"config\"\n```\n\n## Quick Start\n\n```php\nuse App\\Models\\User;\nuse Jackardios\\QueryWizard\\Eloquent\\EloquentQueryWizard;\n\npublic function index()\n{\n    $users = EloquentQueryWizard::for(User::class)\n        -\u003eallowedFilters('name', 'email', 'status')\n        -\u003eallowedSorts('name', 'created_at')\n        -\u003eallowedIncludes('posts', 'profile')\n        -\u003eget();\n\n    return response()-\u003ejson($users);\n}\n```\n\nNow your API supports requests like:\n\n```\nGET /users?filter[name]=John\u0026filter[status]=active\u0026sort=-created_at\u0026include=posts\n```\n\n## Table of Contents\n\n- [Basic Usage](#basic-usage)\n- [Filtering](#filtering)\n- [Sorting](#sorting)\n- [Including Relationships](#including-relationships)\n- [Selecting Fields](#selecting-fields)\n- [Appending Attributes](#appending-attributes)\n- [Resource Schemas](#resource-schemas)\n- [ModelQueryWizard](#modelquerywizard)\n- [Security](#security)\n- [Configuration](#configuration)\n- [Error Handling](#error-handling)\n- [Advanced Usage](#advanced-usage)\n- [API Reference](#api-reference)\n- [Comparison with spatie/laravel-query-builder](#comparison-with-spatielaravel-query-builder)\n\n## Basic Usage\n\n### Creating a Query Wizard\n\n```php\nuse Jackardios\\QueryWizard\\Eloquent\\EloquentQueryWizard;\n\n// From a model class\n$wizard = EloquentQueryWizard::for(User::class);\n\n// From an existing query builder\n$wizard = EloquentQueryWizard::for(User::where('active', true));\n\n// From a relation\n$wizard = EloquentQueryWizard::for($user-\u003eposts());\n```\n\n### Executing Queries\n\n```php\n// Get all results\n$users = $wizard-\u003eget();\n\n// Get first result\n$user = $wizard-\u003efirst();\n$user = $wizard-\u003efirstOrFail();\n\n// Paginate results\n$users = $wizard-\u003epaginate(15);\n$users = $wizard-\u003esimplePaginate(15);\n$users = $wizard-\u003ecursorPaginate(15);\n\n// Get the underlying query builder\n$query = $wizard-\u003etoQuery();\n```\n\n### Configuration Order\n\nConfiguration methods (`allowedFilters`, `allowedSorts`, etc.) **must be called before** query builder methods (`where`, `orderBy`, etc.):\n\n```php\n// ✅ Correct: configuration → builder methods → execution\nEloquentQueryWizard::for(User::class)\n    -\u003eallowedFilters('name')        // configuration\n    -\u003eallowedSorts('created_at')    // configuration\n    -\u003ewhere('active', true)         // builder method\n    -\u003eget();                        // execution\n\n// ❌ Wrong: throws LogicException\nEloquentQueryWizard::for(User::class)\n    -\u003ewhere('active', true)\n    -\u003eallowedFilters('name');       // LogicException!\n```\n\nFor base query scopes, pass a pre-configured query to `for()`:\n\n```php\nEloquentQueryWizard::for(User::where('active', true))\n    -\u003eallowedFilters('name')\n    -\u003eget();\n```\n\n`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.\n\n## Filtering\n\nFilters allow API consumers to narrow down results based on specific criteria.\n\n### Basic Filters\n\n```php\nuse Jackardios\\QueryWizard\\Eloquent\\EloquentFilter;\n\nEloquentQueryWizard::for(User::class)\n    -\u003eallowedFilters(\n        'name',                              // Exact match (string shorthand)\n        'email',                             // Exact match (string shorthand)\n        EloquentFilter::exact('status'),     // Explicit exact filter\n        EloquentFilter::partial('bio'),      // LIKE %value%\n    )\n    -\u003eget();\n```\n\n**Request:** `GET /users?filter[name]=John\u0026filter[bio]=developer`\n\n### Available Filter Types\n\n| Type | Factory | Request Example |\n|------|---------|-----------------|\n| Exact | `EloquentFilter::exact('status')` | `?filter[status]=active` |\n| Partial | `EloquentFilter::partial('name')` | `?filter[name]=john` (LIKE %john%) |\n| Scope | `EloquentFilter::scope('popular')` | `?filter[popular]=5000` |\n| Trashed | `EloquentFilter::trashed()` | `?filter[trashed]=with\\|only` |\n| Null | `EloquentFilter::null('deleted_at')` | `?filter[deleted_at]=true` (IS NULL) |\n| Range | `EloquentFilter::range('price')` | `?filter[price][min]=10\u0026filter[price][max]=100` |\n| Date Range | `EloquentFilter::dateRange('created_at')` | `?filter[created_at][from]=2024-01-01\u0026filter[created_at][to]=2024-12-31` |\n| JSON Contains | `EloquentFilter::jsonContains('tags')` | `?filter[tags]=laravel,php` |\n| Operator | `EloquentFilter::operator('age', FilterOperator::GREATER_THAN)` | `?filter[age]=18` (age \u003e 18) |\n| Operator (dynamic) | `EloquentFilter::operator('price', FilterOperator::DYNAMIC)` | `?filter[price]=\u003e=100` (price \u003e= 100) |\n| Callback | `EloquentFilter::callback('custom', fn($q, $v, $p) =\u003e ...)` | `?filter[custom]=value` |\n| Passthrough | `EloquentFilter::passthrough('context')` | Captured but not applied |\n\n### Filter Options\n\nAll filters support fluent modifiers:\n\n```php\nEloquentFilter::exact('status')\n    -\u003ealias('state')                           // URL parameter name: ?filter[state]=...\n    -\u003edefault('active')                        // Default value when not in request\n    -\u003eprepareValueWith(fn($v) =\u003e strtolower($v))  // Transform before applying\n    -\u003ewhen(fn($v) =\u003e $v !== 'all')             // Skip filter if returns false\n    -\u003eallowStructuredInput()                   // Accept structured raw input, still validate prepared value\n    -\u003easBoolean()                              // Convert 'true'/'1'/'yes' to bool\n```\n\n**Filter-specific modifiers:**\n\n```php\n// Range filter\nEloquentFilter::range('price')-\u003eminKey('from')-\u003emaxKey('to')\n\n// Date range filter\nEloquentFilter::dateRange('created_at')\n    -\u003efromKey('start')-\u003etoKey('end')\n    -\u003edateFormat('Y-m-d')\n\n// JSON contains filter\nEloquentFilter::jsonContains('tags')-\u003ematchAny()  // Default: matchAll()\n\n// Null filter\nEloquentFilter::null('deleted_at')-\u003ewithInvertedLogic()  // IS NOT NULL\n\n// Scope filter\nEloquentFilter::scope('byAuthor')-\u003ewithModelBinding()  // Load model by ID\n```\n\n### Built-in Filter Payload Validation\n\nBuilt-in filters validate the shape of their input before `prepareValueWith()` and `apply()` run.\n\n- `exact`, `partial`, `operator`: scalar or flat list of scalars\n- `scope`: single value or flat list without nested arrays\n- `null`, `trashed`: scalar only\n- `range`, `dateRange`: array with boundary keys (`min`/`max`, `from`/`to`) or a flat list with at least two values\n\nMalformed payloads such as `?filter[name][foo][bar]=Alpha` now raise `InvalidFilterQuery::invalidFormat(...)` instead of reaching SQL generation or PHP warnings.\n\nIf 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.\n\n`disable_invalid_filter_query_exception` only suppresses unknown-filter errors. It does not suppress malformed `filter` payload format errors.\n\n### Relation Filtering\n\nFilters with dot notation automatically use `whereHas`:\n\n```php\nEloquentFilter::exact('posts.status')  // Filters users by their posts' status\n\n// Disable this behavior:\nEloquentFilter::exact('posts.status')-\u003ewithoutRelationConstraint()\n```\n\n## Sorting\n\nAllow API consumers to sort results.\n\n### Basic Sorts\n\n```php\nuse Jackardios\\QueryWizard\\Eloquent\\EloquentSort;\n\nEloquentQueryWizard::for(User::class)\n    -\u003eallowedSorts('name', 'created_at', EloquentSort::field('email'))\n    -\u003edefaultSorts('-created_at')  // Applied only when ?sort is absent\n    -\u003eget();\n```\n\n**Request:** `?sort=name` (asc), `?sort=-name` (desc), `?sort=-created_at,name` (multiple)\n\n`?sort=` is treated as an invalid request and throws `InvalidSortQuery`.\n\n### Available Sort Types\n\n| Type | Factory | Description |\n|------|---------|-------------|\n| Field | `EloquentSort::field('created_at')` | Sort by column |\n| Count | `EloquentSort::count('posts')` | Sort by relationship count |\n| Relation | `EloquentSort::relation('orders', 'total', 'sum')` | Sort by aggregate (min, max, sum, avg, count, exists) |\n| Callback | `EloquentSort::callback('custom', fn($q, $dir, $p) =\u003e ...)` | Custom logic |\n\n## Including Relationships\n\nEager load relationships based on request parameters.\n\n### Basic Includes\n\n```php\nuse Jackardios\\QueryWizard\\Eloquent\\EloquentInclude;\n\nEloquentQueryWizard::for(User::class)\n    -\u003eallowedIncludes(\n        'posts',                               // Relationship (string shorthand)\n        'postsCount',                          // Count (auto-detected by suffix)\n        EloquentInclude::exists('subscription'),\n    )\n    -\u003edefaultIncludes('profile')               // Used only when ?include is absent\n    -\u003eget();\n```\n\n**Request:** `?include=posts,postsCount,subscriptionExists`\n\n`?include=` explicitly disables includes for that request and does not merge defaults.\n\n### Available Include Types\n\n| Type | Factory | Description |\n|------|---------|-------------|\n| Relationship | `EloquentInclude::relationship('posts')` | Eager load with `with()` |\n| Count | `EloquentInclude::count('posts')` | Load count with `withCount()` |\n| Exists | `EloquentInclude::exists('posts')` | Check existence with `withExists()` |\n| Callback | `EloquentInclude::callback('custom', fn($q, $rel) =\u003e ...)` | Custom logic |\n\nIncludes ending with \"Count\" or \"Exists\" are auto-detected as count/exists includes.\n\nWhen 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`).\n\n## Selecting Fields\n\nAllow sparse fieldsets (JSON:API compatible).\n\n```php\nEloquentQueryWizard::for(User::class)\n    -\u003eallowedFields('id', 'name', 'email', 'posts.id', 'posts.title')\n    -\u003eget();\n```\n\n**Request:** `?fields[user]=id,name\u0026fields[posts]=id,title` or `?fields=id,name`\n\n`?fields=` means an explicit empty root fieldset. `?fields[posts]=` means an explicit empty fieldset for `posts`.\n\nIf a `count` / `exists` include is active, `?fields=` still hides normal root columns but keeps the included runtime attribute visible.\n\n### Relation Fields\n\nUse **relation name** as the key, not table name:\n\n```php\n// Model: Task with createdBy(): BelongsTo\u003cUser\u003e\nEloquentQueryWizard::for(Task::class)\n    -\u003eallowedIncludes('createdBy')\n    -\u003eallowedFields('id', 'title', 'createdBy.id', 'createdBy.name')\n    -\u003eget();\n\n// ✅ ?fields[createdBy]=id,name\n// ❌ ?fields[users]=id,name — won't work\n```\n\n### Relation Field Modes\n\n```php\n// config/query-wizard.php\n'optimizations' =\u003e [\n    'relation_select_mode' =\u003e 'safe',  // 'safe' (recommended) or 'off'\n],\n```\n\n**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.\n\n**Off mode**: No automatic handling — you must include all required FK columns manually.\n\n## Appending Attributes\n\nAppend computed model attributes (accessors) to results.\n\n```php\n// Model\nclass User extends Model\n{\n    protected function fullName(): Attribute\n    {\n        return Attribute::get(fn() =\u003e \"{$this-\u003efirst_name} {$this-\u003elast_name}\");\n    }\n}\n\n// Query Wizard\nEloquentQueryWizard::for(User::class)\n    -\u003eallowedAppends('full_name', 'posts.reading_time')\n    -\u003edefaultAppends('full_name')\n    -\u003eget();\n```\n\n**Request:** `?append=full_name,posts.reading_time`\n\n`?append=` explicitly disables appends for that request and does not merge defaults.\n\n## Parameter Semantics\n\nDefaults are applied only when the corresponding parameter is completely absent.\n\n- `?include=` means \"include nothing\"\n- `?append=` means \"append nothing\"\n- `?fields=` means \"show no root fields\", except active `count` / `exists` include attributes remain visible\n- `?fields[relation]=` means \"show no fields for that relation\"\n- `?sort=` is invalid and throws `InvalidSortQuery`\n\n## Resource Schemas\n\nFor larger applications, use Resource Schemas to define all query capabilities in one place.\n\n### Creating a Schema\n\n```php\nuse Jackardios\\QueryWizard\\Schema\\ResourceSchema;\nuse Jackardios\\QueryWizard\\Contracts\\QueryWizardInterface;\n\nclass UserSchema extends ResourceSchema\n{\n    public function model(): string\n    {\n        return User::class;\n    }\n\n    public function filters(QueryWizardInterface $wizard): array\n    {\n        return ['name', EloquentFilter::exact('status')];\n    }\n\n    public function sorts(QueryWizardInterface $wizard): array\n    {\n        return ['name', 'created_at'];\n    }\n\n    public function includes(QueryWizardInterface $wizard): array\n    {\n        return ['posts', 'profile', 'postsCount'];\n    }\n\n    public function fields(QueryWizardInterface $wizard): array\n    {\n        return ['id', 'name', 'email', 'status'];\n    }\n\n    public function appends(QueryWizardInterface $wizard): array\n    {\n        return ['full_name'];\n    }\n\n    public function defaultSorts(QueryWizardInterface $wizard): array\n    {\n        return ['-created_at'];\n    }\n\n    public function defaultFilters(QueryWizardInterface $wizard): array\n    {\n        return ['status' =\u003e 'active'];  // Applied when filter is absent\n    }\n}\n```\n\n### Using Schemas\n\n```php\n// With EloquentQueryWizard\n$users = EloquentQueryWizard::forSchema(UserSchema::class)-\u003eget();\n\n// With ModelQueryWizard (same schema!)\n$user = User::find(1);\n$processed = ModelQueryWizard::for($user)-\u003eschema(UserSchema::class)-\u003eprocess();\n```\n\n### Schema Overrides\n\n```php\nEloquentQueryWizard::forSchema(UserSchema::class)\n    -\u003edisallowedFilters('status')        // Remove from schema\n    -\u003edisallowedIncludes('posts')\n    -\u003eallowedAppends('extra')            // Add to schema\n    -\u003eget();\n```\n\n### Wildcard Support in disallowed*()\n\n| Pattern | Meaning |\n|---------|---------|\n| `'*'` | Block everything |\n| `'posts.*'` | Block direct children only |\n| `'posts'` | Block relation and all descendants |\n\n### Context-Aware Schemas\n\nSchema methods receive the wizard instance for conditional logic:\n\n```php\npublic function includes(QueryWizardInterface $wizard): array\n{\n    $includes = ['posts', 'profile'];\n\n    // Count/exists only work with EloquentQueryWizard\n    if ($wizard instanceof EloquentQueryWizard) {\n        $includes[] = EloquentInclude::count('posts');\n    }\n\n    return $includes;\n}\n```\n\n## ModelQueryWizard\n\nFor processing already-loaded model instances. Handles includes, fields, and appends — **not** filters or sorts.\n\nCall 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.\n\n```php\nuse Jackardios\\QueryWizard\\ModelQueryWizard;\n\n$user = User::find(1);\n\n$processed = ModelQueryWizard::for($user)\n    -\u003eallowedIncludes('posts', 'comments')\n    -\u003eallowedFields('id', 'name', 'email')\n    -\u003eallowedAppends('full_name')\n    -\u003eprocess();\n```\n\n| Feature | Behavior |\n|---------|----------|\n| Includes | Loads missing with `loadMissing()` |\n| Fields | Hides non-requested with `makeHidden()` |\n| Appends | Adds with `append()` |\n| Filters/Sorts | Ignored |\n\n## Security\n\n### Request Limits\n\nBuilt-in protection against resource exhaustion attacks:\n\n| Setting | Default | Description |\n|---------|---------|-------------|\n| `max_include_depth` | 3 | Max nesting (e.g., `posts.comments.author` = 3) |\n| `max_includes_count` | 10 | Max includes per request |\n| `max_filters_count` | 20 | Max filters per request |\n| `max_appends_count` | 20 | Max appends per request |\n| `max_sorts_count` | 5 | Max sorts per request |\n\nConfigure in `config/query-wizard.php`. Set to `null` to disable.\n\n### ScopeFilter Model Binding\n\nBy default, `ScopeFilter` passes values as-is. Enable model binding with caution:\n\n```php\nEloquentFilter::scope('byAuthor')-\u003ewithModelBinding()\n```\n\n**Warning:** Model binding resolves by ID **without authorization checks**. Add checks in your scope if needed.\n\n## Configuration\n\nKey configuration options (`config/query-wizard.php`):\n\n```php\nreturn [\n    'parameters' =\u003e [\n        'includes' =\u003e 'include',   // ?include=posts\n        'filters' =\u003e 'filter',     // ?filter[name]=John\n        'sorts' =\u003e 'sort',         // ?sort=-created_at\n        'fields' =\u003e 'fields',      // ?fields[user]=id,name\n        'appends' =\u003e 'append',     // ?append=full_name\n    ],\n\n    'count_suffix' =\u003e 'Count',     // postsCount → count include\n    'exists_suffix' =\u003e 'Exists',   // postsExists → exists include\n\n    'disable_invalid_filter_query_exception' =\u003e false,  // Throw on invalid filter\n    // ... similar for sort, include, field, append\n\n    'request_data_source' =\u003e 'query_string',  // 'query_string' or 'body' (body only, query string ignored)\n    'apply_filter_default_on_null' =\u003e false,  // Apply default() when filter value is null/empty\n\n    'naming' =\u003e [\n        'convert_parameters_to_snake_case' =\u003e false,  // ?filter[firstName] → first_name\n    ],\n\n    'optimizations' =\u003e [\n        'relation_select_mode' =\u003e 'safe',  // 'safe' or 'off'\n    ],\n\n    'fields' =\u003e [\n        'use_allowed_as_default' =\u003e false,\n    ],\n\n    'limits' =\u003e [\n        'max_include_depth' =\u003e 3,\n        'max_includes_count' =\u003e 10,\n        'max_filters_count' =\u003e 20,\n        'max_appends_count' =\u003e 20,\n        'max_sorts_count' =\u003e 5,\n        'max_append_depth' =\u003e 3,\n    ],\n];\n```\n\nWhen `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.\n\n`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.\n\n## Error Handling\n\nAll exceptions extend `InvalidQuery` (extends Symfony's `HttpException`):\n\n| Exception | Description |\n|-----------|-------------|\n| `InvalidFilterQuery` | Unknown filter |\n| `InvalidSortQuery` | Unknown sort |\n| `InvalidIncludeQuery` | Unknown include |\n| `InvalidFieldQuery` | Unknown field |\n| `InvalidAppendQuery` | Unknown append |\n| `MaxFiltersCountExceeded` | Too many filters |\n| `MaxIncludeDepthExceeded` | Include nesting too deep |\n| ... | (similar for other limits) |\n\n### Global Handler (Laravel 11+)\n\n```php\n// bootstrap/app.php\n-\u003ewithExceptions(function (Exceptions $exceptions) {\n    $exceptions-\u003erender(function (InvalidQuery $e) {\n        return response()-\u003ejson([\n            'error' =\u003e class_basename($e),\n            'message' =\u003e $e-\u003egetMessage(),\n        ], $e-\u003egetStatusCode());\n    });\n})\n```\n\n## Advanced Usage\n\n### Batch Processing\n\nAll execution methods apply post-processing (field masking, appends) automatically:\n\n```php\n$wizard-\u003eget();\n$wizard-\u003epaginate(15);\n$wizard-\u003echunk(100, fn($users) =\u003e ...);\n$wizard-\u003elazy()-\u003eeach(fn($user) =\u003e ...);\n```\n\n### Manual Post-Processing\n\nFor methods not wrapped by wizard (`find()`, `findMany()`):\n\n```php\n$user = $wizard-\u003etoQuery()-\u003efind($id);\n$wizard-\u003eapplyPostProcessingTo($user);\n```\n\n### Laravel Octane\n\nFully compatible. `QueryParametersManager` uses `scoped()` binding for per-request instances.\n\n## API Reference\n\nSee [docs/api-reference.md](docs/api-reference.md) for complete method reference.\n\n## Comparison with spatie/laravel-query-builder\n\n| Feature | Query Wizard | Spatie |\n|---------|:---:|:---:|\n| **Filters** | | |\n| Exact, Partial, Scope, Trashed, Callback | Yes | Yes |\n| Range, Date Range, Null, JSON Contains | Yes | No |\n| Passthrough, Conditional (`when()`) | Yes | No |\n| Value transformation (`prepareValueWith()`) | Yes | No |\n| **Sorts** | | |\n| Field, Callback | Yes | Yes |\n| Relationship count/aggregate | Yes | No |\n| **Includes** | | |\n| Relationship, Count, Exists, Callback | Yes | Yes |\n| Default includes | Yes | No |\n| **Appends** | | |\n| Appends with nesting | Yes | No |\n| **Architecture** | | |\n| Resource Schemas | Yes | No |\n| `disallowed*()` methods | Yes | No |\n| ModelQueryWizard | Yes | No |\n| **Security** | | |\n| Request limits | Yes | No |\n\n## Requirements\n\n- PHP 8.1+\n- Laravel 10, 11, or 12\n\n## Testing\n\n```bash\ncomposer test\n```\n\n## Upgrading\n\nSee [UPGRADE.md](UPGRADE.md) for migration guides between versions.\n\n## License\n\nThe MIT License (MIT). Please see [License File](LICENSE) for more information.\n\n## Credits\n\n- [Salavat Salakhutdinov](https://github.com/jackardios)\n- Inspired by [spatie/laravel-query-builder](https://github.com/spatie/laravel-query-builder) by [Spatie](https://spatie.be)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjackardios%2Flaravel-query-wizard","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fjackardios%2Flaravel-query-wizard","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjackardios%2Flaravel-query-wizard/lists"}