Skip to content

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.

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.

app/Http/Controllers/InvoiceController.php
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);
}

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.

  • sort lists every allowed sort in both directions — issued_at and -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"].
  • include lists everything the allow-list makes legal, which is more than the names you wrote: a bare string like 'lines' also permits linesCount and linesExists in Spatie, and a nested 'customer.address' permits customer on the way — so those appear in the enum too. The Count/Exists suffixes are read from Spatie’s own config if you’ve renamed them. An include built with an AllowedInclude::* 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.

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:

  1. 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.
  2. 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 @property description — the same text the response body’s schema shows, so the two can never disagree.
  3. A derived line for the names Spatie mints. The Count/Exists forms a bare include legalizes are described mechanically — “Count of related customer records.” / “Whether related customer records 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.

app/Models/Invoice.php
/** The customer this invoice bills. */
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
// app/Http/Controllers/InvoiceController.php
QueryBuilder::for(Invoice::class)->allowedIncludes([
'customer',
// Line items, one per SKU.
'lines',
]);

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).

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.

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]:

app/Enums/InvoiceStatus.php
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.php
protected $casts = ['status' => InvoiceStatus::class];
// app/Http/Controllers/InvoiceController.php
QueryBuilder::for(Invoice::class)
->allowedFilters([AllowedFilter::exact('status')]);

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.

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: HasUuids documents type: string with format: uuid, HasUlids the ulid format — either wins over a stale cast on the key — and otherwise $keyType decides between an integer and 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.
  • belongsTo foreign 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 written ownerKey names — through that column’s own cast or key declaration.
app/Models/Customer.php
final class Customer extends Model
{
use HasUuids;
}
// app/Models/Invoice.php — also HasUuids; customer_id is a foreignUuid column, no cast anywhere
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
// app/Http/Controllers/InvoiceController.php
QueryBuilder::for(Invoice::class)
->allowedFilters([AllowedFilter::exact('id'), AllowedFilter::exact('customer_id')]);

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 belongsTo calls in one method — refuses both arms’ written keys rather than picking one;
  • an ownerKey naming 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.

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 into allowedFilters(), as above, is the supported form.
  • Vendor code. A method in vendor/ is never opened, whatever it returns.

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 nudge
AllowedFilter::exact('status'); // documents the enum's values instead

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');

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.

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'),
]);

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.

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'),
]);

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.)

Chained modifiers are recovered too:

  • ->default('sent') sets the parameter’s schema.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. A default on 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’s description.
  • ->nullable() appends “Accepts null to filter for absent values.” to the description. It does not add null as an enum value — the empty filter is a request form, not one of the enum’s cases.
AllowedFilter::exact('status')->default('sent')->nullable();

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:

app/Filters/PopularityFilter.php
#[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.php
QueryBuilder::for(Invoice::class)
->allowedFilters([AllowedFilter::custom('popular', new PopularityFilter)]);

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:

type, format and example have no per-entry rival to lose to, so the attribute is simply the only claim on them.

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')]);

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.

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:

app/Models/Invoice.php
/**
* @property int $total The grand total in minor units.
*/
QueryBuilder::for(Invoice::class)->allowedFields([
// The human-readable invoice number.
'number',
'total',
'customer.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.

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.

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.

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].

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.

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.

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 allowedAppends from the package (v7 keeps only the request-side plumbing), so there is no allow-list to read and no append parameter is documented. An app that implements appends itself — reading $request->appends() off Spatie’s QueryBuilderRequest — 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 guessed page would 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. A belongsTo foreign key needs no dotted path: customer_id types 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_terminals doesn’t change that (see the receiver rule). Declare page and per_page with #[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.