Skip to content

Add support for Laravel's JSON:API resources - #1086

Open
s-shiryaev wants to merge 1 commit into
knuckleswtf:v5from
s-shiryaev:feat/json-api-resource
Open

Add support for Laravel's JSON:API resources#1086
s-shiryaev wants to merge 1 commit into
knuckleswtf:v5from
s-shiryaev:feat/json-api-resource

Conversation

@s-shiryaev

@s-shiryaev s-shiryaev commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

This PR adds support for Laravel's JSON:API resources. Closes #1073

Design decisions

Nothing new to learn: with is what drives it. If you already write @apiResourceModel App\Models\Post with=tags (or with: ['tags'] on the attribute), those relations now show up as relationships + included in the example response, and the example request gets ?include=tags to match. No extra include: field to fill in — the two lists are always the same thing.

Without with, the example response stays what it is today. No relations are guessed for you, so nothing new appears in the body — the relationships the resource declares are listed in the docs for the include parameter instead of being silently baked into the response.

include and fields[<type>] are documented for you, from the relationships and attributes the resource declares. Anything you wrote yourself (say, an @queryParam include) wins, and the new json_api.document_query_parameters config key turns the whole thing off if you'd rather not have them.

The available relationships are listed in the parameter description. Same for fields[<type>].

The example requests send Accept: application/vnd.api+json. A value you picked yourself wins, with one exception: the generic application/json that the shipped config/scribe.php puts there via StaticData gets upgraded, since on a JSON:API endpoint it's a leftover default rather than a choice.

Nested relations in with are followed all the way down. with: ['comments.author'] puts every level into the response's included, so every level gets its own fields[<type>] too.

A resource with no model no longer takes the whole run down. You get a warning naming the endpoint, that one response is skipped, and generation continues.

Examples

Relations from with show up in the response, and in the example request as ?include=author,tags #[ResponseFromApiResource(name: PostResource::class, model: Post::class, with: ['author', 'tags'])] image
A nested relation is followed to the end, so every type along the way gets its own fields[…] #[ResponseFromApiResource(name: PostResource::class, model: Post::class, with: ['comments.author', 'tags'])] image
Without with the response is unchanged, and the declared relationships are listed on the include parameter instead #[ResponseFromApiResource(name: PostResource::class, model: Post::class)] image

Compatibility

Guarded by class_exists() everywhere, and the tests skip via markTestSkipped() in setUp() before the fixtures are ever autoloaded.

Known limitations

  • fetch() was kept as a wrapper over the new fetchResponse(), with its return type unchanged — it's a public static method that third-party strategies may well be using. The new $with / $documentJsonApiQueryParameters arguments were appended with defaults, and the documenting flag defaults to false so existing fetch() callers don't suddenly start writing into $endpointData->queryParameters.
  • type is derived from the class name by the framework (classBasename()->basename('Resource')->snake()->pluralStudly()), unless the resource overrides toType().
  • fields[<type>] is only documented for relationships listed in with — the attribute list of a related resource is only readable once the relation is actually loaded on the example model. Relationships declared with an int key (['tags']) or a Closure are skipped, since the resource class isn't statically known there;
  • @responseField paths need the JSON:API prefixtitle lives at data.attributes.title. Not something this PR changes (the envelope is the resource's own doing, before and after), just worth knowing when moving an endpoint over to a JSON:API resource. It won't break outright either: there's a fallback to the short name.

Changes

  • src/Extracting/Shared/JsonApiResourceTools.php (new) — detection, reading declared relationships/attributes, resolving resource types, and writing the include / fields[<type>] parameters, the Accept request header and the response headers.
  • src/Extracting/Shared/ApiResourceResponseTools.php — added fetchResponse() returning the full JsonResponse; the synthetic request now carries query parameters; \Throwable guard around instantiation and rendering, so a resource with no model warns and skips instead of aborting the run; the request container binding is now restored in a finally.
  • src/Extracting/Strategies/Responses/UseApiResourceTags.php, UseResponseAttributes.php — pass with through, fill in headers, skip the response when rendering failed.
  • config/scribe.phpjson_api.document_query_parameters.
  • phpstan.neon — ignore class.notFound in JsonApiResourceTools.php with reportUnmatched: false, so running the matrix locally on 9–11 doesn't get false errors.
  • teststests/Strategies/Responses/JsonApiResourceTest.php, tests/GenerateDocumentation/JsonApiOutputTest.php, plus BaseLaravelTest::skipIfNoJsonApiResources() and JSON:API fixtures.

Laravel 12.45 added Illuminate\Http\Resources\JsonApi. Documenting one of those resources with @apiResource or #[ResponseFromApiResource] produced an example response with no `relationships` and no `included`, lost the JSON:API content type, and could take the whole generation down.

Everything is behind class_exists(), so older Laravel versions are unaffected and the new tests skip themselves there.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dedicated support for Laravel 13's first-party JsonApiResource

1 participant