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>], or bare fields |
“Fields to return: id, total.” |
| a paginating terminal | page, or cursor |
“Page number.” / “Opaque cursor for the next/previous 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": "Substring match on `customer_id`.", "schema": { "type": "string" } }, { "name": "filter[status]", "in": "query", "required": false, "description": "Substring match on `status`.", "schema": { "type": "string" } }, { "name": "include", "in": "query", "required": false, "description": "Include related resources: customer, lines.", "style": "form", "explode": false, "schema": { "type": "array", "items": { "type": "string", "enum": ["customer", "customerCount", "customerExists", "lines", "linesCount", "linesExists"] } } }, { "name": "page", "in": "query", "required": false, "description": "Page number.", "schema": { "type": "integer", "default": 1, "minimum": 1 } }, { "name": "sort", "in": "query", "required": false, "description": "Sort by: issued_at, total (prefix `-` for descending).", "style": "form", "explode": false, "schema": { "type": "array", "items": { "type": "string", "enum": ["issued_at", "-issued_at", "total", "-total"] } } }]You wrote no parameter annotations — yet filter[status], filter[customer_id], sort, include,
and page are all documented, alongside a paginated response. The excerpt is trimmed for the page:
each value enum also carries per-value descriptions and
SDK member names.
A bare string in allowedFilters is a partial (substring) filter in Spatie, which is why both
descriptions say substring. Rename the page key at the call site — ->paginate(20, ['*'], 'p') — and the document
follows it. There’s no per_page beside it here because paginate(20) fixes the size at that call site;
hand the size to a helper that reads it off the request and
that key is documented too.
Sorts and includes are enums of the allow-list
Section titled “Sorts and includes are enums of the allow-list”The allow-lists are closed sets, so sort and include document their exact legal values — not a
free string with the values buried in prose. Both accept comma-separated lists
(sort=-issued_at,total), so both are modeled the way enum filters
are: an array with style: form, explode: false, the serialization that produces the comma form,
with the values as the item enum.
sortlists every allowed sort in both directions —issued_atand-issued_at— so a client picking from the enum gets descending without decoding the-convention from the description (it’s still explained there too). An allow-listed->defaultSort('issued_at')becomes"default": ["issued_at"]— an array, because the parameter is one, and because several defaults compose:->defaultSort('-issued_at', 'total')documents["-issued_at", "total"].includelists everything the allow-list makes legal, which is more than the names you wrote: a bare string like'lines'also permitslinesCountandlinesExistsin Spatie, and a nested'customer.address'permitscustomeron the way — so those appear in the enum too. TheCount/Existssuffixes are read from Spatie’s own config if you’ve renamed them. An include built with anAllowedInclude::*factory permits only its own name, and that’s what’s documented.
A default only lands on the schema when it belongs there. Spatie lets defaultSort() name a sort
that isn’t in the allow-list — it applies when the parameter is omitted, but a client can’t send
it — and a value outside the enum would make the schema contradict itself. An out-of-enum default
moves to the description instead (and one such default moves them all, rather than splitting the
answer between schema and prose):
{ "name": "sort", "in": "query", "required": false, "style": "form", "explode": false, "description": "Sort by: name (prefix `-` for descending). Defaults to `-created_at`.", "schema": { "type": "array", "items": { "type": "string", "enum": ["name", "-name"] } } }One Spatie setting changes the shape itself: a custom delimiter in config/query-builder.php —
read from the same config as the parameter names. The array modelling
above is OpenAPI’s comma serialization, so under any other separator it would document requests
your API doesn’t parse. sort, include, each fields group, and enum filters
degrade to a plain string with the separator and the values named in the description; an empty
delimiter turns splitting off entirely, so the single-value schema emits directly:
{ "name": "sort", "in": "query", "required": false, "description": "Sort by: name (prefix `-` for descending). Values are separated by `|`. Defaults to `-name`.", "schema": { "type": "string" } }The enum states what is meaningful whatever the strict-mode setting: an unknown sort or include is
never useful, whether Spatie rejects it with a 400 (strict
mode, the default) or silently ignores it. Strict mode decides which of those the document’s error
responses describe — not the value domain.
Describing the values
Section titled “Describing the values”Every value in these enums can carry its own description, and most of them can be described without writing anything new. Docuccino answers from three places, most explicit first:
- A comment above the allow-list entry — the same mechanism filter comments use, now on sort, include, and fields entries too. One comment in a shared query class describes that value on every endpoint using the list.
- Prose you already wrote on the model. An include falls back to its relation method’s
docblock summary; a sort (and a bare fields entry) falls back to the column’s
@propertydescription — the same text the response body’s schema shows, so the two can never disagree. - A derived line for the names Spatie mints. The
Count/Existsforms a bare include legalizes are described mechanically — “Count of relatedcustomerrecords.” / “Whether relatedcustomerrecords exist.” — which both explains them and marks them as machine-made.
A described sort documents its - form with the same text plus (descending). Dotted include
paths and type.-prefixed fields groups take comments only — their prose lives on another model,
where no static reader can safely follow.
/** The customer this invoice bills. */public function customer(): BelongsTo{ return $this->belongsTo(Customer::class);}
// app/Http/Controllers/InvoiceController.phpQueryBuilder::for(Invoice::class)->allowedIncludes([ 'customer', // Line items, one per SKU. 'lines',]);{ "name": "include", "in": "query", "required": false, "description": "Include related resources: customer, lines.", "style": "form", "explode": false, "schema": { "type": "array", "items": { "type": "string", "enum": ["customer", "customerCount", "customerExists", "lines", "linesCount", "linesExists"], "x-enumDescriptions": { "customer": "The customer this invoice bills.", "customerCount": "Count of related `customer` records.", "customerExists": "Whether related `customer` records exist.", "lines": "Line items, one per SKU.", "linesCount": "Count of related `lines` records.", "linesExists": "Whether related `lines` records exist." }, "x-enum-varnames": ["Customer", "CustomerCount", "CustomerExists", "Lines", "LinesCount", "LinesExists"] } } }The value-keyed x-enumDescriptions map appears only when every value is described — some
renderers hide values missing from it — and the same prose always ships in parallel as
x-enum-descriptions, the array spelling code generators read, with an empty slot for any value
that has none. Both are trimmed together here, as is x-enumNames (the same names,
NSwag’s spelling).
SDK-ready member names
Section titled “SDK-ready member names”Values like -total are exact wire bytes but hostile identifiers: class-enum SDK generators must
mint a member name from each value, and their sanitizers collide on the - pair or mangle dots. So
every value enum ships its member names — x-enum-varnames (OpenAPI Generator, the TypeScript
toolchain) and x-enumNames (NSwag), same names in both — minted from the value alone: total →
Total, -total → TotalDesc, customer.address → CustomerAddress. Adding a value never
renames a neighbor, so a generated SDK’s enum members stay put as the API grows.
The same hints ship on component enums — one decoration
standard everywhere — governed by one switch,
representation.enums.naming: the default
names emits both spellings, a single-key keyword pins one tool’s, and none keeps documents
lean. In the rare case two values in one enum would mint the same member name, Docuccino publishes
distinct value-derived names instead and emits a query-builder.enum-name-collision diagnostic —
the fix is renaming one of the colliding allow-list entries.
Typed filters from your model
Section titled “Typed filters from your model”When an exact filter names a column Docuccino can type, the parameter gets a real schema instead
of defaulting to a string. It reads the subject model straight from QueryBuilder::for(Invoice::class)
— no annotation, no extra config — and answers from three places: the column’s
Eloquent cast, the model’s primary
key, and the related model behind a belongsTo foreign key.
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 on `status`. 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) |
type: string |
An uncast column isn’t necessarily a plain string either: the model’s primary key and its
belongsTo foreign keys are typed from the model itself;
every other uncast column stays a string. If Docuccino can’t resolve the subject model, every filter
falls back to a string.
Keys and foreign keys, without a cast
Section titled “Keys and foreign keys, without a cast”The columns clients filter on most — ids — rarely carry a cast: Laravel has no built-in uuid cast,
and a foreignUuid() column type lives in a migration, where no static reader can see it. Docuccino
types these from what the model itself declares:
- The primary key. An exact filter on the key column follows the key’s declaration:
HasUuidsdocumentstype: stringwithformat: uuid,HasUlidstheulidformat — either wins over a stale cast on the key — and otherwise$keyTypedecides between anintegerand a plain string. It’s the same answer the model’s route-bound path parameter gets, so a filter and a path segment can never describe the same key differently. belongsToforeign keys. A filter on a foreign-key column is typed off the key it references: the related model’s primary key, or the column a writtenownerKeynames — through that column’s own cast or key declaration.
final class Customer extends Model{ use HasUuids;}
// app/Models/Invoice.php — also HasUuids; customer_id is a foreignUuid column, no cast anywherepublic function customer(): BelongsTo{ return $this->belongsTo(Customer::class);}
// app/Http/Controllers/InvoiceController.phpQueryBuilder::for(Invoice::class) ->allowedFilters([AllowedFilter::exact('id'), AllowedFilter::exact('customer_id')]);{ "name": "filter[customer_id]", "in": "query", "required": false, "description": "Exact match on `customer_id`.", "schema": { "type": "string", "format": "uuid" } },{ "name": "filter[id]", "in": "query", "required": false, "description": "Exact match on `id`.", "schema": { "type": "string", "format": "uuid" } }The foreign-key hop is deliberately literal-minded. It reads relations written the idiomatic way —
return $this->belongsTo(Customer::class); with literal arguments, positional or named, chained
modifiers like ->withDefault() included — and computes the default foreign key exactly as Laravel
does (snake($relation).'_'.$related->getKeyName(), honoring a literal $relation argument).
Anything it would have to guess at, it refuses, and the filter stays a plain string:
- a computed argument — a variable class name, a built key string — is never guessed from, and it guards as well as declines: a relation whose foreign key is written but whose other arguments aren’t refuses exactly that column, while one whose foreign key is itself computed could serve any column, so it suppresses foreign-key typing across the whole model;
- a conditional relation — two
belongsTocalls in one method — refuses both arms’ written keys rather than picking one; - an
ownerKeynaming a column that is neither the related model’s key nor cast has no known type; - a column two relations both claim as their foreign key has no single truthful answer;
- a
morphTo()column’s related model varies per row, so it has no single key to reference.
Editing any belongsTo target re-documents the endpoint — every named related class’s file joins
the build’s dependency set exactly as the subject model’s does, refused relations included.
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 — or, uncast, the key or foreign key it is. 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]. |
beginsWith('name') / endsWith('name') |
A begins-with / ends-with substring match — documented as a string. | Comment, docblock, or #[QueryParameter]. |
groupOr('search', [...]) / groupAnd('search', [...]) |
One key whose value every grouped member applies — 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. Wherever a kind reads a column’s cast, the
key and foreign-key typing applies too — an uncast id column
types the same through exact, operator, a callback, or a custom filter. AllowedFilter::trashed()
called with no name is documented under Spatie’s own default name, filter[trashed].
A filter group publishes the one key it is declared under and nothing else — its members are
conditions on that key, not parameters of their own. The kind is read from the factory you call, so
beginsWithStrict and endsWithStrict — the names these two carried before v7 — are recognised the
same way on an older install.
Anything Docuccino can’t reduce to one column or a known type is documented at exactly the certainty
that remains. It never guesses. A kind whose value is a string by construction — a non-equality
operator (GREATER_THAN), a dotted relation path (customer.name) — stays a plain string. A
multi-clause closure or __invoke is different: a callback or custom filter takes whatever its
own code takes, so its parameter publishes an explicit empty schema — unconstrained, rather than
pinned to a type the filter’s code could contradict. Each one the finished document still
publishes untyped is reported (query-builder.untyped-filter),
because an unconstrained parameter reaches a generated client as an untyped value: add
#[QueryParameter(type: 'string')] to the filter class, or to the action, and it carries a type
again. Anything else that types the same parameter — a validation rule on the filter key, a
#[QueryParameter] on the action — settles it just as well, and silences the report with it.
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 — the arguments you write there name the filter, so
the typing never depends on opening the factory body:
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 — its cast, or its key/foreign-key type,
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.
One further fact is read from inside a foldable factory body: the custom filter class it wraps, so a shared filter class can declare its schema once for every call site.
Sometimes the call site names nothing at all: the entry is built by a method, and the filter’s public name is written inside it. Docuccino then folds what that method returns, with the call-site arguments bound to its parameters — a parameter’s own constant default counts as one. Instance method or static, it makes no difference; what matters is where the name is written:
ListQueryBuilder::for(Invoice::class) ->allowedFilters( $this->searchFilter(), // name and closure column live in the body $this->columnFilter('status', 'status_code'), // the arguments bind to the parameters ListFilters::status(), // a static helper, called with no name at all ...$this->allowedFilters(), // an array helper: one entry per item ) ->allowedSorts($this->issuedAtSort());Each entry arrives with everything its own body says about it: the kind, the
internal column, the column a callback closure filters on, the
->default() and ->nullable() modifiers applied in there, and the comment written above an entry
inside an array-returning helper — which becomes that filter’s description, exactly as it does at the
call site. The same fold answers for allowedSorts, allowedIncludes, allowedFields, and
defaultSort.
For that to work, each of those methods needs a single unconditional return. That’s the real
requirement, not a style preference: Docuccino reads one returned expression, and a method with two arms
has no honest answer to fold.
// Folds: one return, and the name is right there.private function statusFilter(): AllowedFilter{ return AllowedFilter::exact('status', 'status_code');}
// Doesn't: two returns, and picking one would document a filter your app may never register.private function referenceFilter(): AllowedFilter{ if ($this->exactReferences) { return AllowedFilter::exact('reference'); }
return AllowedFilter::partial('reference');}An entry Docuccino can’t fold is never guessed at: nothing it declares reaches the allow-list read off
the chain, you get a query-builder.unresolved-entry warning naming the file and line, and every other
entry on the list is documented as usual. What the recovered entries no longer are is the whole list,
so a sort, include or fields allow-list missing one entry is documented as a plain string rather than an
enum — an enum short of a value would tell a generated client to reject a value your endpoint accepts.
The other allow-lists on the same endpoint keep their enums. The warning is about that recovery, so
documenting the entry yourself with #[QueryParameter] publishes the parameter and leaves the warning
standing and true: the chain is still short an entry, and the list still widens because of it. Three
more shapes stay out of reach:
- A hand-off to another method. The fold reads one body; it doesn’t step into a second, so
return $this->buildFilter($key);stays unresolved. A factory call in there is fine —return self::enum($key, OrderStatus::class);reads exactly as it would at a call site. - Spread arguments into the helper.
$this->columnFilter(...$args)breaks the match between arguments and parameters, so nothing binds. Spreading the helper’s result intoallowedFilters(), as above, is the supported form. - Vendor code. A method in
vendor/is never opened, whatever it returns.
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”The generated description states the contract
Section titled “The generated description states the contract”Every filter is described without you writing anything, and the description says what the parameter does — the match it performs and the key it performs it on — rather than naming the kind you built it from. The reader can’t see your allow-list, so a label like “custom filter” tells them nothing:
| Filter kind | Generated description |
|---|---|
exact('status') |
Exact match on status. |
partial('name') / a bare 'name' |
Substring match on name. |
beginsWith('name') |
Prefix match on name. |
endsWith('name') |
Suffix match on name. |
operator('score', …) |
Compares score against the value. |
groupOr('search', […]) |
Matches records where at least one of the conditions grouped under search holds. |
groupAnd('search', […]) |
Matches records where every condition grouped under search holds. |
belongsTo('customer') |
Matches records belonging to the given customer. |
trashed() |
Soft-delete filter: with includes soft-deleted records, only returns only soft-deleted; omit to exclude them. |
scope('popular'), callback('active', fn), custom('flag', F::class) |
Filters the result set by popular. |
The last row is the honest answer, not a gap: the matching a scope, a closure, or a custom filter class performs is your code’s, so the description states that the parameter filters and on which key, and claims no semantics it can’t see. A filter built by your own factory gets the same line, and so does any kind a future Spatie release adds. A wrong “exact match” would point a generated client at a contract that doesn’t exist; a vague one only costs it a hint.
The name in the description is always the public one — the key a client sends. An
internal column is used for cast lookup and never surfaces:
AllowedFilter::exact('status', 'status_code') is described as “Exact match on status.”
Anything you write yourself wins outright — a comment above the entry
or a #[QueryParameter] replaces the generated line entirely. The
whereIn, null, separator, and default notes are appended either
way, because they describe the request form rather than the matching.
Overriding the generated prose
Section titled “Overriding the generated prose”The table above is the default, not the last word. If your API’s own voice says it differently — or a
kind’s sentence reads wrong for your domain — set the lead sentence per kind in docuccino.yaml:
documents: default: integrations: query_builder: filter_descriptions: exact: 'Matches `%field%` exactly.' custom: 'Narrows the result set by `%field%`, as configured for your account.'It’s a merge, not a replacement: the two kinds above are overridden and every other kind keeps its default sentence, so naming one does not blank the rest.
%field% is the one supported token. It interpolates the filter’s public name — the key a
client sends, never an internal column — and nothing else in the string is
interpolated, so a sentence with no token is published exactly as written (trashed ships one).
Everything else about the description is unchanged. The request-form notes still compose after the configured lead, in the same order they compose after a default one:
QueryBuilder::for(Invoice::class) ->allowedFilters([ AllowedFilter::exact('status'), ]);{ "name": "filter[status]", "in": "query", "required": false, "description": "Matches `status` exactly. Accepts a comma-separated list of values (matched as `whereIn`).", "style": "form", "explode": false, "schema": { "type": "array", "items": { "$ref": "#/components/schemas/InvoiceStatus" } }}A comment above the entry still wins: the comment describes
that filter, a configured sentence describes every filter of that kind. And a key naming no filter
kind — the kinds are the left column of the table above — is reported as config.unknown-filter-kind
rather than silently doing nothing.
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 the allow-list method, which is the normal way to write it.
On a filter entry the comment becomes the whole parameter’s description, as above. On a sort,
include, or fields entry it describes that value inside the parameter’s enum instead — see
Describing the values — while the parameter’s own description stays
generated (the allowed values and the - descending convention).
A comment above the entry is the narrowest thing anyone can say about a filter, so it wins over a
description on the filter class it registers: the class describes every
call site, the comment describes this one. (A #[QueryParameter] on the action is narrower still,
and stays ahead of both.)
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. Adefaulton the filter class covers every other call site, and a chained one here wins for this entry, exactly as a comment does over the class’sdescription.->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, format, 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. format composes exactly as it does at route level: it applies after
type — an explicit format wins over one the type implied — and it works alone, riding the default
string schema, so #[QueryParameter(name: 'ignored', format: 'uuid')] documents
type: string, format: uuid. 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 — and a backed-enum argument written at a factory call site
(above) also stays ahead of the wrapped class’s attribute, being the more specific declaration.
The attribute sits on the class, so it describes every call site — which makes anything one entry says about itself the narrower claim, and the narrower claim wins:
- a comment above the entry beats the attribute’s
description; - a chained
->default()beats the attribute’sdefault.
type, format and example have no per-entry rival to lose to, so the attribute is simply the only
claim on them.
Shared filter classes, declared once
Section titled “Shared filter classes, declared once”A reusable filter class tends to be registered through a small static factory rather than a literal
AllowedFilter::custom(...) at every call site. The class-level attribute travels through that too:
when the factory body is a single unconditional return of AllowedFilter::custom(...) — the same
fold rule as every other helper, and vendor bodies are never opened — Docuccino recovers the filter
class it wraps (new UuidFilter(...), new self, or a UuidFilter::class argument) and reads its
attribute, its __invoke column, exactly as if the AllowedFilter::custom call sat at the call site.
So a filter enforcing a rule at runtime can publish that rule once, for every endpoint that uses it:
// app/Filters/UuidFilter.php — rejects anything that isn't a UUID with a 422#[QueryParameter(name: 'ignored', format: 'uuid', description: 'Matches one record by its public id.')]final class UuidFilter implements Filter{ public function __invoke(Builder $query, mixed $value, string $property): void { if (! is_string($value) || ! Str::isUuid($value)) { throw ValidationException::withMessages([$property => 'Must be a valid UUID.']); }
$query->where($property, $value); }
public static function allowed(string $key, ?string $column = null): AllowedFilter { return AllowedFilter::custom($key, new self, $column ?? $key); }}
// Any list endpoint:QueryBuilder::for(Invoice::class) ->allowedFilters([UuidFilter::allowed('customer_id'), UuidFilter::allowed('position_id')]);{ "name": "filter[customer_id]", "in": "query", "required": false, "description": "Matches one record by its public id.", "schema": { "type": "string", "format": "uuid" } },{ "name": "filter[position_id]", "in": "query", "required": false, "description": "Matches one record by its public id.", "schema": { "type": "string", "format": "uuid" } }A wrapper factory on another class works the same way — ListFilters::uuid('customer_id') returning
AllowedFilter::custom($key, new UuidFilter) reads the attribute off UuidFilter. And a factory
whose body can’t fold (one that branches, say) can carry the attribute on the factory class
itself instead; failing both, the call-site column typing above stands, and nothing is guessed.
Two things to keep at the call site: ->default() and ->nullable() chained inside a named
factory’s body aren’t merged onto the entry — chain them where you register the filter, where a
->default() also outranks any default the class declares — and the published parameter names
still come from the allowedFilters keys, never from the class.
Sparse fieldsets
Section titled “Sparse fieldsets”allowedFields entries are grouped by their type prefix — everything before the last dot, which
is how Spatie itself splits them, so schema.articles.title belongs to the schema.articles group —
one parameter per type. A bare field name has no type to bracket, so it lands on the unbracketed
fields parameter, which Spatie reads as the subject model’s own columns. Each group is a closed
set, so each parameter is an enum of its allow-list,
in the same comma-list modelling as sort and include — and its values take
comments and @property prose the same way:
/** * @property int $total The grand total in minor units. */
QueryBuilder::for(Invoice::class)->allowedFields([ // The human-readable invoice number. 'number', 'total', 'customer.name',]);{ "name": "fields", "in": "query", "required": false, "description": "Fields to return: number, total.", "style": "form", "explode": false, "schema": { "type": "array", "items": { "type": "string", "enum": ["number", "total"], "x-enumDescriptions": { "number": "The human-readable invoice number.", "total": "The grand total in minor units." }, "x-enum-varnames": ["Number", "Total"] } } },{ "name": "fields[customer]", "in": "query", "required": false, "description": "Fields to return: name.", "style": "form", "explode": false, "schema": { "type": "array", "items": { "type": "string", "enum": ["name"], "x-enum-varnames": ["Name"] } } }The bare group’s values fall back to the subject model’s @property prose; a prefixed group names
another type’s columns, so its values are described by comments only — fields[customer] above is
named but undescribed until one is written.
Fields degrade exactly where sorts and includes do, and nowhere else: below Spatie
v7, under a custom
delimiter, and where an entry of that same
allow-list could not be recovered. Spatie’s
convert_field_names_to_snake_case changes only the column names it puts in the SELECT; the
allow-list is still validated exactly as you wrote it, so the enum stays true either way.
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 the same style as the document’s other errors (your own handler’s shape where it renders one, else
the framework default), 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_include_query_exception are true in
config/query-builder.php, because any one of them left on can still produce the 400.
Spatie renamed that last key in v7 — it was disable_invalid_includes_query_exception, plural, up to
v6. Docuccino reads whichever spelling the installed version reads, so a published config file left
un-renamed by an upgrade documents the 400 the server will still throw.
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.
When the query object is the builder
Section titled “When the query object is the builder”The other common shape skips the hop entirely: the query class extends QueryBuilder and
configures itself in its own constructor, and the container hands the finished object to the action.
final class InvoiceListQuery extends QueryBuilder{ public function __construct() { parent::__construct(Invoice::query()->with(['customer']));
$this->allowedFilters([AllowedFilter::exact('status'), 'reference']) ->allowedSorts(['issued_at', 'total']) ->allowedIncludes(['customer']) ->defaultSort('issued_at'); }}
final class InvoiceController{ public function index(InvoiceListQuery $query): AnonymousResourceCollection { return InvoiceResource::collection($query->paginate()); }}Nothing the action body does leads to those allow-lists — the container built the object, and a new
isn’t a call Docuccino follows. So it traces the constructor of the query class as a root of its
own, into the same facts as the action’s own trace: filter[status], filter[reference], sort,
include, and page all land. The parent::__construct(Invoice::query()) call is what
names the subject model, so filters still type off its casts, and the query class file joins the
fragment-cache dependency set exactly as the hop’s does. A paginating helper of your own on the query
class behaves as it does anywhere else — see custom pagination terminals.
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 in docuccino.yaml so the pagination parameters are still added:
documents: default: integrations: query_builder: pagination_terminals: ['paginateList']A custom terminal is treated as length-aware (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, and a custom one takes no page-name argument of its own, so it
keeps the default key. If your helper renames the key on the paginate() call it forwards to, that’s
inside the helper and out of sight — declare the real key with
#[QueryParameter].
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, or returned by a method that branches or can’t be folded) |
query-builder.unresolved-entry (warning), naming the file and line |
The entry is omitted and every other entry is documented; that list loses its enum (a short one would be false), the others keep theirs |
| A paginating terminal reached, but no allow-lists and no default sort recovered from the chain | query-builder.no-allowlists-recovered (info), naming the action |
The page parameter 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 |
| spatie/laravel-query-builder older than v7 | query-builder.legacy-package-version (info) |
sort/include/fields as plain strings, values named in the description |
| Two enum values minting one SDK member name | query-builder.enum-name-collision (info) |
Distinct value-derived names — rename an allow-list entry to clear it |
Four things are outside what the trace models today, and none produces a diagnostic:
-
Appends — Spatie removed
allowedAppendsfrom the package (v7 keeps only the request-side plumbing), so there is no allow-list to read and noappendparameter is documented. An app that implements appends itself — reading$request->appends()off Spatie’sQueryBuilderRequest— declares the parameter once:#[QueryParameter('append', description: 'Comma-separated accessors to append to each result.')]public function index() { /* … */ } -
A page key renamed to something unreadable —
->paginate(20, ['*'], $key)documents no page parameter at all, since a guessedpagewould name a key the endpoint never reads. -
Relationship-path columns (
customer.name) — the filter is documented, but as a plain string; only columns on the subject model’s own table are typed. AbelongsToforeign key needs no dotted path:customer_idtypes off the related key directly. -
Pagination that doesn’t run through a query builder — a terminal called on anything but a builder isn’t recognized, and
pagination_terminalsdoesn’t change that (see the receiver rule). Declarepageandper_pagewith#[QueryParameter].
The parameters above are this package’s whole set. A list endpoint that paginates without Spatie Query Builder still gets its page key on its own — see pagination.
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.
Where the same gap repeats across the API — a shared append convention, a pagination helper every
list action calls — an OperationExtension
states it once for the whole document instead, keying on the action’s return type, a route-name prefix,
or an attribute of your own. See Once, for a whole
document.