Skip to main content

D123 — JSON:API sparse fieldsets/includes are query-string only, app-wide

Architecture decision record. Status, thematic clusters, and how to record a new ADR: the decision log index.

Context

Laravel 13's native Illuminate\Http\Resources\JsonApi\JsonApiRequest has two defects that surface together as a provider-agnostic, framework-level request-contract collision (docs/tracking/jsonapi-fields-collision.md):

  1. Body collision. sparseFields()/sparseIncluded() read the request's fields/include input via $this->array('fields') / $this->string('include') — the MERGED input bag (query string AND body), not the query string alone. Any request body attribute literally named fields or include collides with the JSON:API reserved query keys.
  2. Non-string fatal. sparseFields() unconditionally explode(',', $fieldsets)s each top-level member (vendor/laravel/framework/src/Illuminate/Http/Resources/JsonApi/ JsonApiRequest.php:28). If that member is array-shaped — a domain payload, or ?fields[type][]=x on the query string itself — explode() throws a TypeError, not while validating the request, but while the JSON:API resource layer RENDERS the response.

Onboarding's application-submit endpoint has a body attribute literally named fields (/data/attributes/fields, carrying provider endorsement/onboarding-requirement data) whose value is array-shaped — the first place this was observed (app-modules/onboarding/tests/Feature/EndorsementAllowListTest.php, before this change, drove its ALLOWED-path assertions through the command bus to route around the 500). It is not onboarding-specific: every JSON:API endpoint in the app is exposed to ?fields[type][]=x on the query string alone, with no request body involved at all.

Decision

Sparse fieldsets and includes are resolved from the query string only, app-wide, via a Foundation subclass substituted through the vendor's own resolution seam:

  • Modules\Foundation\Http\Requests\QueryScopedJsonApiRequest extends JsonApiRequest overrides sparseFields()/sparseIncluded() to source from $this->query('fields') / $this->query('include') instead of the merged bag, then defers to parent::sparseFields() / parent::sparseIncluded() (which short-circuit once their cache is primed) so the maxRelationshipDepth handling in the parent stays vendor-owned. A non-string top-level fields member is ignored (that key resolves to no scoping), not fatal; a non-array top-level fields value resolves to no sparse fieldsets at all.
  • Modules\Foundation\Http\Concerns\ResolvesQueryScopedJsonApiRequest overrides the vendor trait method Concerns\ResolvesJsonApiRequest::resolveJsonApiRequestFrom() to return our subclass instead of the vendor's JsonApiRequest. JsonApiResource and JsonApiCollection both use it, so every response-rendering path resolves our request class.
  • JsonApiResource::newCollection() is overridden to return Foundation's own JsonApiCollection (mirroring the existing collection() static factory), so no internal vendor path — not just the explicit ::collection() call — can reach the vendor's plain AnonymousResourceCollection/request class.
  • Onboarding's fields body attribute is kept as-is — it is a published API contract (5 occurrences in client.json) on the requirements-descriptor/submit/RFI-respond endpoints, and renaming it would not fix the underlying query-string collision anyway (defect 2 above exists independent of any body field name).

Consequences

  • The behavior change is scoped precisely to request-BODY-carried fields/include attributes: they are no longer read as JSON:API sparse-fieldset/include input by any endpoint. Query-string sparse fieldsets/includes are unaffected (still resolved, still explode()-based per key).
  • ?fields[type][]=x on the query string no longer 500s anywhere in the app; the offending non-string member is silently ignored rather than scoping that type.
  • Vendor coupling is now explicit and loud: every override carries #[\Override], so a Laravel upgrade that changes the parent's caching contract or method signatures fails visibly (a PHPStan/type error) rather than silently diverging.
  • Exit condition: delete QueryScopedJsonApiRequest and ResolvesQueryScopedJsonApiRequest, and revert the newCollection() override, once upstream Laravel scopes sparse-fieldset/include resolution to the query string and tolerates non-string members.

Follow-ups

  • File an upstream laravel/framework issue for both defects (non-blocking).
  • Re-check this subclass's parent contract at every Laravel minor version bump.
  • Sort/filter query parameters are not read by these vendor classes today — out of scope here.
  • docs/tracking/jsonapi-fields-collision.md — the original defect writeup, now resolved.
  • D122 — the preceding ADR in the decision log.

← Engineering wiki index