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):
- Body collision.
sparseFields()/sparseIncluded()read the request'sfields/includeinput 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 namedfieldsorincludecollides with the JSON:API reserved query keys. - Non-string fatal.
sparseFields()unconditionallyexplode(',', $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][]=xon the query string itself —explode()throws aTypeError, 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 JsonApiRequestoverridessparseFields()/sparseIncluded()to source from$this->query('fields')/$this->query('include')instead of the merged bag, then defers toparent::sparseFields()/parent::sparseIncluded()(which short-circuit once their cache is primed) so themaxRelationshipDepthhandling in the parent stays vendor-owned. A non-string top-levelfieldsmember is ignored (that key resolves to no scoping), not fatal; a non-array top-levelfieldsvalue resolves to no sparse fieldsets at all.Modules\Foundation\Http\Concerns\ResolvesQueryScopedJsonApiRequestoverrides the vendor trait methodConcerns\ResolvesJsonApiRequest::resolveJsonApiRequestFrom()to return our subclass instead of the vendor'sJsonApiRequest.JsonApiResourceandJsonApiCollectionbothuseit, so every response-rendering path resolves our request class.JsonApiResource::newCollection()is overridden to return Foundation's ownJsonApiCollection(mirroring the existingcollection()static factory), so no internal vendor path — not just the explicit::collection()call — can reach the vendor's plainAnonymousResourceCollection/request class.- Onboarding's
fieldsbody attribute is kept as-is — it is a published API contract (5 occurrences inclient.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/includeattributes: they are no longer read as JSON:API sparse-fieldset/include input by any endpoint. Query-string sparse fieldsets/includes are unaffected (still resolved, stillexplode()-based per key). ?fields[type][]=xon 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
QueryScopedJsonApiRequestandResolvesQueryScopedJsonApiRequest, and revert thenewCollection()override, once upstream Laravel scopes sparse-fieldset/include resolution to the query string and tolerates non-string members.
Follow-ups
- File an upstream
laravel/frameworkissue 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.
Related
docs/tracking/jsonapi-fields-collision.md— the original defect writeup, now resolved.- D122 — the preceding ADR in the decision log.