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

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

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

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, no cast) type: string

If Docuccino can’t resolve the subject model, every filter falls back to a string.

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.

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

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

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.
  • ->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, 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. 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.

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.

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.

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.

Spatie lets you rename the request keys under parameters in its own config/query-builder.phpfilter, 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) 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 — no append parameter 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.