Spatie Query Builder
Activates automatically when Spatie Query Builder is installed. It documents the query parameters your list endpoints accept — without you writing a single parameter annotation.
What it documents
Section titled “What it documents”Docuccino follows your controller’s query as it’s built and turns the allowed operations into query parameters:
| Allow-list | Parameters | Description Docuccino writes |
|---|---|---|
allowedFilters |
one filter[<name>] each |
The filter’s kind (or your comment), plus any whereIn / null notes |
allowedSorts |
sort |
“Sort by: issued_at, total (prefix - for descending).” |
allowedIncludes |
include |
“Include related resources: customer, lines.” |
allowedFields |
fields[<type>] |
“Comma-separated fields: id, total.” |
| a paginating terminal | page + per_page, or cursor + per_page |
“Page number.” / “Items per page.” |
->defaultSort('issued_at') becomes the sort parameter’s default. Includes cover relationships
plus the count, exists, and aggregate include kinds — they’re all names in the allow-list, so
they’re all documented.
Crucially, this works even when the query builder is assembled several method calls deep — for example behind a reusable base query class. Docuccino traces through your helper methods to recover the real list of allowed filters and sorts.
public function index(): AnonymousResourceCollection{ $invoices = QueryBuilder::for(Invoice::class) ->allowedFilters(['status', 'customer_id']) ->allowedSorts(['issued_at', 'total']) ->allowedIncludes(['customer', 'lines']) ->paginate();
return InvoiceResource::collection($invoices);}"parameters": [ { "name": "filter[customer_id]", "in": "query", "required": false, "description": "Partial-match filter", "schema": { "type": "string" } }, { "name": "filter[status]", "in": "query", "required": false, "description": "Partial-match filter", "schema": { "type": "string" } }, { "name": "include", "in": "query", "required": false, "description": "Include related resources: customer, lines.", "schema": { "type": "string" } }, { "name": "page", "in": "query", "required": false, "description": "Page number.", "schema": { "type": "integer", "default": 1, "minimum": 1 } }, { "name": "per_page", "in": "query", "required": false, "description": "Items per page.", "schema": { "type": "integer", "default": 15, "minimum": 1 } }, { "name": "sort", "in": "query", "required": false, "description": "Sort by: issued_at, total (prefix `-` for descending).", "schema": { "type": "string" } }]You wrote no parameter annotations — yet filter[status], filter[customer_id], sort, include,
page, and per_page are all documented, alongside a paginated response.
A bare string in allowedFilters is a partial (substring) filter in Spatie, which is why those
two say so. per_page defaults to 15 unless you pass a page size at the call site —
->paginate(20) documents "default": 20.
Typed filters from your model’s casts
Section titled “Typed filters from your model’s casts”When an exact filter’s column has an Eloquent cast,
Docuccino types the parameter from that cast instead of defaulting to a string. It reads the subject
model straight from QueryBuilder::for(Invoice::class) — no annotation, no extra config.
The biggest win is enum casts. Cast a column to a
backed enum and the filter documents its
exact allowed values — including per-case descriptions when you annotate the enum with
#[CaseDescription]:
enum InvoiceStatus: string{ #[CaseDescription('Not yet sent to the customer.')] case Draft = 'draft';
#[CaseDescription('Sent and awaiting payment.')] case Sent = 'sent';
case Paid = 'paid';}
// app/Models/Invoice.phpprotected $casts = ['status' => InvoiceStatus::class];
// app/Http/Controllers/InvoiceController.phpQueryBuilder::for(Invoice::class) ->allowedFilters([AllowedFilter::exact('status')]);{ "name": "filter[status]", "in": "query", "required": false, "description": "Exact-match filter. Accepts a comma-separated list of values (matched as `whereIn`).", "style": "form", "explode": false, "schema": { "type": "array", "items": { "$ref": "#/components/schemas/InvoiceStatus" } }},// components.schemas — one canonical enum, shared by every use site"InvoiceStatus": { "type": "string", "enum": ["draft", "sent", "paid"], "x-enumDescriptions": { "draft": "Not yet sent to the customer.", "sent": "Sent and awaiting payment." }}Enums are hoisted to a reusable component by default, so the filter references it rather than
repeating the values. Set
representation.enums.components to false to
inline the enum at every use site instead.
Why an array? Spatie’s exact filter treats a comma-joined value (filter[status]=draft,sent) as
a whereIn list. Documenting the filter as a single string would mark that legal request invalid, so
Docuccino models an enum filter as an array of the allowed values with style: form, explode: false
— the serialization that produces the comma form — and notes the behavior in the description.
Other casts map to their natural scalar type:
| Cast | Filter schema |
|---|---|
boolean |
type: boolean |
integer, timestamp |
type: integer |
float / double / real |
type: number |
datetime, immutable_datetime |
type: string, format: date-time |
date, immutable_date |
type: string, format: date |
datetime:<custom format> |
type: string, with the format named in the description |
array, collection, json |
type: [array, object] (decodes to whichever it stored) |
object |
type: object |
encrypted:<inner> |
the inner cast’s schema (decrypts, then casts) |
AsStringable, AsUri, AsHtmlString |
type: string |
AsArrayObject, AsFluent, AsEncryptedArrayObject |
type: object |
AsCollection, AsEncryptedCollection |
type: array |
| A backed enum | type: array of the backing values (above) |
Anything else (decimal, hashed, AsEnumCollection, an unrecognized custom caster, no cast) |
type: string |
If Docuccino can’t resolve the subject model, every filter falls back to a string.
What each filter kind infers
Section titled “What each filter kind infers”Type information isn’t limited to exact filters — every kind carries a fact Docuccino can recover, and it reads only what the kind actually exposes:
| Filter kind | What’s inferred | How to override |
|---|---|---|
exact('status') |
The column’s cast. A backed enum becomes a comma whereIn array of its values. |
Comment, docblock, or #[QueryParameter]. |
partial('name') / a bare 'name' |
A substring match — documented as a string. | Comment, docblock, or #[QueryParameter]. |
scope('popular') |
The scopePopular scope’s value parameter type — a native scalar, or a backed enum (values + descriptions). |
Type the scope parameter; or #[QueryParameter]. |
callback('active', fn) |
The column of a single $query->where('active', $value) in the closure, then that column’s cast. |
Comment above the entry, or #[QueryParameter]. |
custom('flag', new F) |
A #[QueryParameter] on the filter class F, else the column of a single where in F::__invoke. |
See custom filter classes. |
operator('score', FilterOperator::EQUAL) |
The internal column’s cast — equality operators (EQUAL, DYNAMIC) only. |
#[QueryParameter]. |
beginsWithStrict('name') / endsWithStrict('name') |
A begins-with / ends-with substring match — documented as a string. | Comment, docblock, or #[QueryParameter]. |
belongsTo('team') |
A relationship filter — documented as a string. | Comment, docblock, or #[QueryParameter]. |
trashed() |
A fixed with / only string enum, with the omitted-value default explained. |
— |
A single-value comparison (scope, callback, operator) documents an enum as one value; only exact
uses the comma whereIn array. AllowedFilter::trashed() called with no name is documented under
Spatie’s own default name, filter[trashed].
Anything Docuccino can’t reduce to one column or a known type — a non-equality operator
(GREATER_THAN), a multi-clause closure or __invoke, a dotted relation path (customer.name) —
stays a plain string. It never guesses.
Filter factories (a ListFilters-style helper)
Section titled “Filter factories (a ListFilters-style helper)”If you funnel the recurring filter idioms through a small factory that returns an AllowedFilter,
Docuccino types the filter from the call site — no descent into the factory body needed:
final class ListFilters{ public static function enum(string $key, string $enumClass, ?string $column = null): AllowedFilter { /* … */ } public static function boolean(string $key, ?string $column = null): AllowedFilter { /* … */ }}
// In your query:->allowedFilters([ ListFilters::enum('status', OrderStatus::class), // → the enum's values + x-enumDescriptions ListFilters::boolean('active'), // → the `active` column's boolean cast])Any argument that is a backed-enum class-string names the filter’s value domain, so it’s documented
with that enum’s backing values and #[CaseDescription] prose directly (a single-value comparison, so
a scalar enum — not the whereIn array). With no enum argument, the filter’s key is taken as the
column and typed off the model cast (so a boolean/uuid idiom types correctly); a name that isn’t a
column — a multi-column search — stays a plain string. Spatie’s own AllowedFilter::* factories are
unaffected. Keep a single unconditional return AllowedFilter::…() in each factory method so it stays
statically resolvable.
Partial filters over an enum column
Section titled “Partial filters over an enum column”A partial filter (and a bare-string filter, which is partial by default) matches a substring, not
an enum member — so Docuccino keeps it a string rather than pretending its values are the enum’s. When
the column is enum-cast it emits an info diagnostic (query-builder.partial-on-enum) suggesting
AllowedFilter::exact if you’d rather document the exact values:
AllowedFilter::partial('status'); // substring match → stays a string, plus an info nudgeAllowedFilter::exact('status'); // documents the enum's values insteadInternal column names
Section titled “Internal column names”When a filter’s public name differs from the database column, pass both to exact(). The public name
stays what clients send; Docuccino uses the internal column for the cast lookup:
// Documented as filter[status]; typed from the `status_code` column's cast.AllowedFilter::exact('status', 'status_code');Describing filters
Section titled “Describing filters”A comment becomes the description
Section titled “A comment becomes the description”Write a comment directly above an allow-list entry and it becomes that filter’s description — the simplest possible way to explain a parameter, right where it’s defined:
QueryBuilder::for(Invoice::class) ->allowedFilters([ // The lifecycle stage of the invoice. AllowedFilter::exact('status'), AllowedFilter::partial('customer_name'), ]);{ "name": "filter[status]", "in": "query", "required": false, "description": "The lifecycle stage of the invoice. Accepts a comma-separated list of values (matched as `whereIn`).", "style": "form", "explode": false, "schema": { "type": "array", "items": { "$ref": "#/components/schemas/InvoiceStatus" } }}Both // line comments and /** */ blocks work; Docuccino takes the first sentence, verbatim, and
still appends the whereIn and null notes so the request form stays documented. Two rules:
- the comment’s last line must sit immediately above the entry — a blank line between them means it belongs to nothing;
- the entry must be an item of the array literal you pass to
allowedFilters([...]), which is the normal way to write it.
Comments describe filters. Sorts, includes, and fields carry a generated description instead (the
allowed values and the - descending convention).
Defaults and nullable filters
Section titled “Defaults and nullable filters”Chained modifiers are recovered too:
->default('sent')sets the parameter’sschema.default. The value must be a constant — a computed default can’t be read statically, and the rest of the entry is still documented without it.->nullable()appends “Acceptsnullto filter for absent values.” to the description. It does not addnullas an enum value — the empty filter is a request form, not one of the enum’s cases.
AllowedFilter::exact('status')->default('sent')->nullable();Custom filter classes
Section titled “Custom filter classes”For a custom filter, Docuccino reads the column from a single $query->where(...) in the class’s
__invoke — just like a callback. When the body is more involved, document the parameter with a
#[QueryParameter] on the filter class itself. Its name argument is ignored in this position
(the parameter’s name is the one you gave AllowedFilter::custom); type, description, example,
and default are applied to the filter’s parameter:
#[QueryParameter(name: 'ignored', type: 'int', description: 'Minimum popularity score.', example: 42)]final class PopularityFilter implements Filter{ public function __invoke(Builder $query, mixed $value, string $property): void { $query->whereRaw('popularity(created_at) >= ?', [$value]); }}
// app/Http/Controllers/InvoiceController.phpQueryBuilder::for(Invoice::class) ->allowedFilters([AllowedFilter::custom('popular', new PopularityFilter)]);{ "name": "filter[popular]", "in": "query", "required": false, "description": "Minimum popularity score.", "example": 42, "schema": { "type": "integer" }}type accepts a scalar name (int, string, bool, float) or a backed-enum class-string, which
documents that enum’s values. The class attribute is an override of inference, not the last word: a
#[QueryParameter('filter[popular]')] on the controller action still wins over it, so a caller can
refine a shared filter per endpoint.
Sparse fieldsets
Section titled “Sparse fieldsets”allowedFields entries are grouped by their type.field prefix, one parameter per type:
->allowedFields(['invoices.id', 'invoices.total', 'customer.name'])// → fields[invoices] "Comma-separated fields: id, total."// → fields[customer] "Comma-separated fields: name."A bare field name with no type. prefix has no type to bracket, so it lands on the unbracketed
fields parameter instead.
Strict mode and the 400 response
Section titled “Strict mode and the 400 response”Spatie Query Builder runs in strict mode by default — an unknown filter, sort, or include raises an
InvalidQuery (HTTP 400). Docuccino documents that 400 on every Query Builder operation, rendered
in your chosen error style (framework defaults or the Problem Details preset), so consumers know a
mistyped parameter is rejected rather than silently ignored:
"400": { "description": "Bad Request", "content": { "application/json": { "schema": { "type": "object", "properties": { "message": { "type": "string" } } } } }}It’s omitted in two cases: the document sets
error_responses to none, or you’ve turned
strict mode fully off. “Fully” is the operative word — Docuccino treats strict mode as off only when
all three of disable_invalid_filter_query_exception,
disable_invalid_sort_query_exception, and disable_invalid_includes_query_exception are true in
config/query-builder.php, because any one of them left on can still produce the 400.
Query objects (allow-lists in a separate class)
Section titled “Query objects (allow-lists in a separate class)”You don’t have to build the QueryBuilder chain in the controller. A common pattern keeps the
allowedFilters()/allowedSorts() allow-lists in a dedicated query class and calls it from the
action:
final readonly class InvoiceIndexQuery{ public function query(): ListQueryBuilder // your QueryBuilder subclass { return ListQueryBuilder::for(Invoice::class) ->allowedFilters([AllowedFilter::exact('status'), 'reference']) ->allowedSorts(['issued_at']); }}
final class InvoiceController{ public function index(InvoiceIndexQuery $query) { return $query->query()->paginateList(); }}Docuccino follows the hop: a method call whose return type is your QueryBuilder (a subclass counts)
is followed into that method, so the allow-lists are recovered from wherever they actually live — even
when the query class sits in a different source root from the controller (e.g. a Modules\…\Queries
namespace). Vendor code is never followed. Everything else on this page — enum typing, comments,
defaults, custom filters — works identically through the hop, and the query class file joins the
fragment-cache dependency set, so editing it re-documents the endpoint.
Configuration
Section titled “Configuration”Custom pagination terminals
Section titled “Custom pagination terminals”paginate, simplePaginate, and cursorPaginate are recognized out of the box, and each implies its
paginator kind. If your app paginates through a custom helper method — paginateList() in the example
above — name it so the pagination parameters are still added:
// config/docuccino.php → documents.default.integrations'query_builder' => [ 'pagination_terminals' => ['paginateList'],],A custom terminal is treated as length-aware (page + per_page). If yours wraps
cursorPaginate(), leave it out of this list — the trace reaches the real cursorPaginate call
inside it and documents cursor correctly.
Only the outermost terminal counts, so a page size passed at the call site
(->paginateList(50)) is the one documented.
Renamed parameter keys
Section titled “Renamed parameter keys”Spatie lets you rename the request keys under parameters in its own config/query-builder.php —
filter, sort, include, fields. Docuccino reads that config and documents whatever names
you’ve chosen: rename filter to filters and the parameters come out as filters[status]. When
the config isn’t readable it falls back to the package defaults and emits a
query-builder.default-config info diagnostic telling you to publish it, so you’re never left
wondering why the names look generic.
Turning it off
Section titled “Turning it off”integrations.query_builder.enabled => false omits every Query Builder contribution from a document.
That’s the only other switch this integration has — see the
integrations reference.
Limits, and how you’re told
Section titled “Limits, and how you’re told”Docuccino never silently drops a parameter. Everything it can’t recover becomes a named diagnostic on the build:
| Situation | Diagnostic | What lands in the spec |
|---|---|---|
An allow-list entry that isn’t a literal or a factory call (built from a variable, a match, a loop) |
query-builder.unresolved-entry (warning), naming the file and line |
The entry is omitted; every other entry is documented |
| A paginating terminal reached, but no allow-lists recovered at all | query-builder.no-allowlists-recovered (info), naming the action |
Pagination parameters only |
A partial filter over an enum-cast column |
query-builder.partial-on-enum (info) |
The filter, as a plain string |
| Spatie’s config unreadable | query-builder.default-config (info) |
Default parameter names |
Two things are outside what the trace models today, and neither produces a diagnostic:
allowedAppends— noappendparameter is documented. Add one with#[QueryParameter('append')]if your clients use it.- Relationship-path columns (
customer.name) — the filter is documented, but as a plain string; only the subject model’s own columns are typed from casts.
For anything on this list, document the parameter yourself with
#[QueryParameter], addressing it by name:
#[QueryParameter('filter[status]', description: 'Only active invoices.')]public function index() { /* … */ }Values you set with attributes or docblocks always take precedence over what’s inferred.