ark4ne / laravel-json-api
A Lightweight JSON:API Resource for Laravel
Requires
- php: ^8.3
- illuminate/collections: ^12.46 || ^13.0
- illuminate/console: ^12.46 || ^13.0
- illuminate/http: ^12.46 || ^13.0
- illuminate/support: ^12.46 || ^13.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.75
- larastan/larastan: ^3.4
- opis/json-schema: ^2.6
- orchestra/testbench: ^10.0 || ^11.0
- phpat/phpat: ^0.12.5
- phpstan/phpstan: ^2.1
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^11.5 || ^12.5 || ^13.0
- spaze/phpstan-disallowed-calls: ^4.5
Suggests
- phpunit/phpunit: Required by Testing\ResourceAssertions to assert resource definitions in the application tests.
Provides
None
Conflicts
None
Replaces
None
- dev-master
- v2.0.x-dev
- v2.0.0
- v1.9.x-dev
- v1.9.1
- v1.9.0
- v1.8.x-dev
- v1.8.0
- v1.7.x-dev
- v1.7.1
- v1.7.0
- v1.6.x-dev
- v1.6.3
- v1.6.2
- v1.6.1
- v1.6.0
- v1.5.x-dev
- v1.5.2
- v1.5.1
- v1.5.0
- v1.4.x-dev
- v1.4.6
- v1.4.5
- v1.4.4
- v1.4.3
- v1.4.2
- v1.4.1
- v1.4.0
- v1.3.x-dev
- v1.3.8
- v1.3.7
- v1.3.6
- v1.3.5
- v1.3.4
- v1.3.3
- v1.3.2
- v1.3.1
- v1.3.0
- 1.2.x-dev
- v1.2.1
- v1.2.0
- v1.1.10
- v1.1.9
- v1.1.8
- v1.1.7
- v1.1.6
- v1.1.5
- v1.1.4
- v1.1.3
- v1.1.2
- v1.1.1
- v1.1.0
- v1.0.2
- v1.0.1
- v1.0.0
- v0.0.1
- v0.0.0
- v0.0.0-RC1
- dev-feat/versioned-resources
- dev-release/v2.0.0
- dev-v1.6-typed-enum
- dev-v1.6-typed-array
- dev-v1.5-relationships-filters
- dev-22-toresponse-set-a-wrong-name-header-content-type-instead-of-content-type
- dev-19-bug-exception-when-include-relationship-one
This package is auto-updated.
Last update: 2026-11-09 17:21:44 UTC
README
A Lightweight {JSON:API} Resource for Laravel.
Why this package rather than Laravel's native JSON:API?
Laravel (12.46+, 13) ships Illuminate\Http\Resources\JsonApi\JsonApiResource. This package stays independent of it
and interoperates with it: a relationship may relate native resources (Native resources), and a
native JsonApiRequest is read like any request.
| Laravel's native | This package | |
|---|---|---|
id as a string, "data": null, primary data out of included |
✅ | ✅ |
| Attributes resolved after the sparse fieldset | ✅ | ✅ |
| Relationships that are not included | left out | linkage loaded in batch, or left out (relationship.linkage) |
| Typed values: nullable, date format, decimal precision, enum | ❌ | ✅ |
Definition of a resource, validation of include and fields |
❌ | ✅ (JsonApiFormRequest, jsonapi:check) |
Links and meta per relationship, whenIncluded(), can(), filters |
❌ | ✅ |
| Resources that are not Eloquent models | ❌ (exception) | ✅ |
| Public contract for the tooling (OpenAPI) | ❌ | ✅ |
| Error documents, content negotiation | ❌ | ✅ |
Installation
composer require ark4ne/laravel-json-api
Coding agents (Claude Code, Cursor…) can load the skills shipped in vendor/ark4ne/laravel-json-api/skills/:
laravel-json-api (the rules, the reference of every helper, tested recipes) and laravel-json-api-upgrade (the
migration from 1.x).
Config
| Path | Type | Description |
|---|---|---|
describer.nullable |
bool |
For describer notation, defined if a value is nullable by default. |
describer.datetime |
string datetime format |
Default format of $this->datetime(). |
describer.precision |
int \ null |
For describer notation, decimal precision for float value. null for disable rounding. |
describer.strict |
bool |
A value that is not nullable and has no value for null (a date) throws instead of rendering null. APP_DEBUG by default. |
describer.when-has |
bool \ string[] |
For describer notation, Apply automatically whenHas condition on attributes. |
relationship.linkage |
'always' \ 'loaded' \ 'included' |
When a relationship that is not included renders its data: always, only when already loaded, only when included. See Linkage and loading. |
fields.relationships |
'filter' \ 'keep' |
fields[type] restricts the relationships too (filter, JSON:API) or not (keep, 1.x). |
include.max-depth |
int \ null |
Deepest include path accepted; nothing deeper is loaded nor rendered. null: no limit. |
document.jsonapi |
array \ false |
Top-level jsonapi member (version, ext, profile, meta); false to omit it. |
rendering.stages |
class-string[] |
Rendering stages (Ark4ne\JsonApi\Rendering\Stage) run after those of the package. |
Usage
This package is a specialisation of Laravel's JsonResource class.
All the underlying API's are still there, thus in your controller you can still interact
with JsonApiResource classes as you would with the base JsonResource class
Request
This package allows the reading and dynamic inclusion of resources that will be requested in the requests via the "include" parameter.
@see {json:api} fetching-includes
Resource attributes will also be filtered according to the "fields" parameter.
@see {json:api} fetching-fields
You can validate these parameters against the definition of a resource by extending JsonApiFormRequest:
use \Ark4ne\JsonApi\Http\JsonApiFormRequest; class UserFetchRequest extends JsonApiFormRequest { protected string $resource = UserResource::class; }
include must match the relationships of UserResource (and of the resources it relates to), fields their
attributes and relationships. Both are read from the query string only, and may be empty (?include=).
The rules() of the request are added to them; a rule of yours keyed include or fields replaces the
preconfigured one (it stays nullable), a nested key (fields.*) is added to it:
use \Ark4ne\JsonApi\Http\JsonApiFormRequest; use \Ark4ne\JsonApi\Http\Rules\Includes; class PostStoreRequest extends JsonApiFormRequest { protected string $resource = PostResource::class; public function rules(): array { return [ 'data.attributes.title' => ['required', 'string'], 'include' => [new Includes(PostResource::class, maxDepth: 2)], ]; } }
Write requests
jsonApi() reads the resource object of the body from the validated data: a member without rule is missing from it.
To read a relationship cleared ('data' => null, or [] for a to-many one), give its linkage a rule of its own
(data.relationships.tags.data): the rules of its identifiers alone (data.relationships.tags.data.*.id) leave it out
of the validated data. The validation errors are keyed by the path of the member (data.attributes.title).
class PostStoreRequest extends JsonApiFormRequest { protected string $resource = PostResource::class; public function rules(): array { return [ 'data.type' => ['required', 'in:post'], 'data.attributes.title' => ['required', 'string'], 'data.relationships.user.data.id' => ['required', 'exists:users,id'], 'data.relationships.tags.data' => ['sometimes', 'array'], 'data.relationships.tags.data.*.id' => ['required', 'exists:tags,id'], ]; } } // In the controller $document = $request->jsonApi(); $document->type(); // 'post' $document->id(); // null: no client-generated id $document->attributes(); // ['title' => '…'] $document->attribute('title'); // '…' $document->relationship('user'); // ['id' => 1]: the linkage, as sent $document->ids('user'); // ['1']: the ids, as strings $document->hasRelationship('tags'); // false when not sent, true for 'data' => [] (every tag detached) $document->ids('tags'); // []
Include and fields rules
In any other form request, use the rules Rules\Includes and Rules\Fields directly:
use \Ark4ne\JsonApi\Http\Rules\Fields; use \Ark4ne\JsonApi\Http\Rules\Includes; use \Illuminate\Foundation\Http\FormRequest; class UserFetchRequest extends FormRequest { public function rules() { return [ 'include' => ['nullable', new Includes(UserResource::class)], 'fields' => ['nullable', new Fields(UserResource::class)], ]; } }
Rules\Includes validates the include against the relationships of the definition of UserResource, up to the
maximum include depth (include.max-depth, or its second argument). Rules\Fields validates the fields against
the attributes and relationships of the types reachable from it.
Customize validation message
The messages are translations of the package: php artisan vendor:publish --tag=jsonapi-lang copies them to
lang/vendor/jsonapi/en/validation.php, to edit or translate.
| Trans key | default |
|---|---|
jsonapi::validation.fields.invalid |
The selected :attribute is invalid. |
jsonapi::validation.fields.invalid_fields |
":resource" doesn't have fields ":fields". |
jsonapi::validation.fields.invalid_resource |
":resource" doesn't exist. |
jsonapi::validation.includes.invalid |
The selected :attribute is invalid. |
jsonapi::validation.includes.invalid_includes |
":include" doesn't have relationship ":relation". |
jsonapi::validation.includes.invalid_depth |
":include" is deeper than the maximum include depth of :depth. |
Errors
@see {json:api} errors
One line in bootstrap/app.php renders the exceptions as JSON:API error documents, for the requests that expect JSON:
use Ark4ne\JsonApi\Laravel\JsonApi; use Illuminate\Foundation\Configuration\Exceptions; ->withExceptions(function (Exceptions $exceptions) { // Your own render callbacks first: Laravel runs them in order, and every exception has a JSON:API rendering. $exceptions->render(fn (PaymentRequired $e) => response()->json([...], 402)); JsonApi::renderErrors($exceptions); // or for some requests only: JsonApi::renderErrors($exceptions, fn ($request) => $request->is('api/*')); })
The requests rendered are those that expect JSON ($request->expectsJson()), or those the closure accepts; it
receives the request and the exception as Laravel prepared it (ModelNotFoundException is a NotFoundHttpException
then). shouldRenderJsonWhen() is not read: give the same condition. An exception that renders itself (render(),
Responsable) or a ValidationException with a response of its own is still rendered by Laravel.
| Exception | Status | Error objects |
|---|---|---|
ValidationException |
its status (422) |
one per message, source.pointer (/data/attributes/title) or source.parameter (fields[post]) |
AuthenticationException |
401 |
its message |
AuthorizationException |
403, or the status of denyWithStatus() / denyAsNotFound() |
its message |
ModelNotFoundException |
404 |
no detail: the class of the model is not disclosed |
HttpException (abort()) |
its status, with its headers | its message |
| any other | 500 |
its message, class, file and line only when app.debug is on |
{
"jsonapi": {"version": "1.1"},
"errors": [
{"status": "422", "title": "Unprocessable Content", "detail": "The title field is required.", "source": {"pointer": "/data/attributes/title"}}
]
}
Content negotiation
@see {json:api} content-negotiation
The jsonapi route middleware applies the content negotiation of JSON:API 1.1:
Route::middleware('jsonapi')->group(function () { Route::apiResource('posts', PostController::class); });
415 Unsupported Media Typewhen theContent-Typeof the request isapplication/vnd.api+jsonwith a parameter other thanextandprofile, or with an extension the server does not support;406 Not Acceptablewhen everyapplication/vnd.api+jsonof theAcceptheader has such a parameter or extension, or is refused (q=0); anAcceptof*/*orapplication/jsonis served;- every response, these errors included, gets
Vary: Accept.
The supported extensions are those of document.jsonapi.ext; unknown profiles are ignored. Both errors are
HttpExceptions, rendered as error documents by JsonApi::renderErrors(). On every route, with or without the
middleware, the documents are served with the extensions and profiles of document.jsonapi in their Content-Type
(application/vnd.api+json; ext="https://jsonapi.org/ext/atomic"). An application that already has a jsonapi
middleware alias keeps it: alias NegotiateContent under another name.
Error renderers of the application
A renderer implements Ark4ne\JsonApi\Http\Errors\ErrorRenderer; the renderers are asked by decreasing PRIORITY,
the first that supports the exception renders it. Declare yours in config/jsonapi.php (built by the container, so
they may have dependencies):
'errors' => [ 'renderers' => [App\Http\Errors\PaymentRequiredErrorRenderer::class], ],
final readonly class PaymentRequiredErrorRenderer implements ErrorRenderer { // Above the renderers of the package (Validation 400, Authentication / Authorization / ModelNotFound 300, // Http 200, Throwable 0): a renderer of a higher priority supporting the same exceptions replaces theirs. public const PRIORITY = 1000; public function supports(Throwable $exception): bool { return $exception instanceof PaymentRequired; } public function render(Throwable $exception, Request $request): ErrorDocument { return new ErrorDocument(402, [ new ErrorObject(status: 402, title: 'Payment Required', detail: $exception->getMessage(), code: 'payment'), ]); } }
To replace them all, bind your own ErrorRenderers in the container
(ErrorRenderers::default($jsonapi)->with(...), or new ErrorRenderers($jsonapi, ...$renderers)).
Resource
Implementable methods :
protected function toType(Request $request): string; protected function toIdentifier(Request $request): int|string; public function toAttributes(Request $request): iterable; protected function toRelationships(Request $request): iterable; protected function toResourceMeta(Request $request): ?iterable; protected function toDocumentMeta(Request $request): ?iterable;
Example:
use Ark4ne\JsonApi\Resources\JsonApiResource; use Illuminate\Http\Request; class UserResource extends JsonApiResource { public function toAttributes(Request $request): iterable { return [ 'name' => $this->name, 'email' => $this->email, ]; } protected function toResourceMeta(Request $request): ?iterable { return [ 'created_at' => $this->created_at->format(DateTimeInterface::ATOM), 'updated_at' => $this->updated_at->format(DateTimeInterface::ATOM), ]; } protected function toRelationships(Request $request): iterable { return [ 'posts' => PostResource::relationship(fn() => $this->posts, fn() => [ 'self' => "https://api.example.com/user/{$this->id}/relationships/posts", 'related' => "https://api.example.com/user/{$this->id}/posts", ]), 'comments' => CommentResource::relationship(fn() => $this->whenLoaded('comments')), ]; } }
Generate a resource
php artisan make:jsonapi-resource PostResource writes app/Http/Resources/PostResource.php from the model
(--model=Post, which must exist, otherwise App\Models\Post found by the convention; --force overwrites):
- the attributes from the casts (
'published_at' => 'datetime:Y-m-d H:i'gives$this->datetime()->format('Y-m-d H:i'),'decimal:2'gives$this->decimal()->scale(2), an enum gives$this->enum()), then the fillable attributes without cast ($this->mixed()) and the timestamps; neither the key,id,type, the hidden attributes nor the foreign keys of theBelongsTorelations; - the relationships from the public methods typed with a relation, declared without resource class
(
'user' => $this->one()); aMorphTois left as a comment, to complete with its resources.
php artisan vendor:publish --tag=jsonapi-stubs publishes the stub to stubs/jsonapi-resource.stub.
Static analysis
Declare the model of the resource with @extends JsonApiResource<User>: PHPStan and the IDEs then know it everywhere
the resource gives it.
/** * @extends JsonApiResource<User> */ class UserResource extends JsonApiResource { public function toAttributes(Request $request): iterable { return [ // $this->resource is a User; the descriptor is a ValueString<User> 'name' => $this->string(), // the closures of the descriptors receive the User 'email' => $this->string(fn(User $user) => $user->email)->when(fn($request, User $user) => $user->isAdmin()), // MissingValue|bool 'admin' => $this->when($request->user()?->is($this->resource) ?? false, fn() => $this->resource->isAdmin()), ]; } }
The hooks need no @return: they inherit that of the parent (Relation<User>, Value<User>). An annotation of your
own names the model: @return iterable<string, Relation<User>>, not Relation<mixed>.
Definition
The package reads what a resource declares (its type, attributes, relationships and eager-loads) once per class,
without a model: it runs the hooks on a probe that stands for the model, with an empty request. This definition
validates include and fields, plans the eager-loads, and is returned by UserResource::definition().
Hence a few rules:
- No
ifon the request in a hook. The definition sees an empty request: a member added by anifis missing from it, so it cannot be requested nor included. Write conditions with->when(),->unless()or$this->applyWhen(). - Prefer lazy values. A described value (
$this->string()) or a closure is typed and computed only when requested; a value read eagerly ('name' => $this->name) has an unknown type. - Group conditional members with
applyWhen(). The members ofmergeWhen()andmergeUnless()are missing from the definition whatever the condition, with amerge-whenwarning: they are rendered, but cannot be requested byfieldsnor included, and their relations are not eager-loaded.
php artisan jsonapi:check reports what breaks these rules (--strict fails on warnings too), and
php artisan jsonapi:cache (run by php artisan optimize) compiles every definition for production.
toType
Returns resource type.
protected function toType(Request $request): string { return 'user'; }
Without toType() (or calling parent::toType()), the type is that of the #[JsonApiType] attribute:
use Ark4ne\JsonApi\Resources\Attributes\JsonApiType; #[JsonApiType('posts')] class PostResource extends JsonApiResource {}
Otherwise it follows the naming convention of config/jsonapi.php (type.naming), from the name of the resource class
without its Resource suffix (BlogPostResource):
type.naming |
Type |
|---|---|
kebab-singular (default) |
blog-post |
kebab-plural |
blog-posts |
snake-plural |
blog_posts |
a class implementing Ark4ne\JsonApi\Configuration\TypeNaming |
its own |
toIdentifier
@see {json:api} resource-identifier
Returns resource identifier.
protected function toIdentifier(Request $request): int|string { return $this->id; }
Default returns toId() when it gives an id (the hook of Laravel's native resources, null by default), otherwise the
model id.
toAttributes
@see {json:api} resource-attributes
Returns resource attributes.
public function toAttributes(Request $request): iterable { return [ 'name' => $this->name, 'email' => $this->email, ]; }
Without toAttributes(), the attributes of the model are rendered (attributesToArray(): casts, $hidden and
$appends applied), without its key, id, type and the foreign keys of the relationships declared in
toRelationships(): JSON:API represents them as relationships.
Laravel conditional attributes
@see laravel: eloquent-conditional-attributes
Support laravel conditional attributes.
public function toAttributes(Request $request): array { return [ 'name' => $this->name, 'email' => $this->email, // with lazy evaluation 'hash64' => fn() => base64_encode("{$this->id}-{$this->email}"), // Conditional attribute 'secret' => $this->when($request->user()->isAdmin(), 'secret-value'), // Merging Conditional Attributes: applyWhen keeps them in the definition, // mergeWhen hides them (fields validation, eager-loads) with a diagnostic $this->applyWhen($request->user()->isAdmin(), [ 'first-secret' => 123, 'second-secret' => 456.789, ]), ]; }
Described attributes
@see described notation
public function toAttributes(Request $request): array { return [ 'name' => $this->string(), // pass key to describer $this->string('email'), // with lazy evaluation 'hash64' => $this->string(fn() => base64_encode("{$this->id}-{$this->email}")), // Conditional attribute $this->string('secret')->when($request->user()->isAdmin(), 'secret-value'), // Merging Conditional Attributes $this->applyWhen($request->user()->isAdmin(), [ 'first-secret' => $this->integer(fn() => 123), 'second-secret' => $this->float(fn() => 456.789), ]), ]; }
toRelationships
@see {json:api} resources-relationships
Returns resource relationships.
All relationships must be created with ModelResource::relationship.
This allows the definition of the resource and thus the validation of request includes.
If your relation should have been a collection created via the ::collection(...) method, you can simply use ->asCollection().
If you want the relation data to be rendered only when it is present in the request include, you can use the ->whenIncluded() method (see Linkage and loading).
protected function toRelationships(Request $request): array { return [ 'avatar' => AvatarResource::relationship($this->avatar), // with conditional relationship 'administrator' => $this->when($request->user()->isAdmin(), UserResource::relationship(fn() => $this->administrator)), // as collection, with conditional value 'comments' => CommentResource::relationship(fn() => $this->whenLoaded('comments'))->asCollection(), // with relationship (allow to include links and meta on relation) 'posts' => PostResource::relationship(fn() => $this->posts)->links(fn() => [ 'self' => "https://api.example.com/user/{$this->id}/relationships/posts", 'related' => "https://api.example.com/user/{$this->id}/posts", ])->asCollection(), ]; }
toRelationships must returns an array, keyed by string, of JsonApiResource or JsonApiCollection.
Laravel conditional relationships
@see laravel: eloquent-conditional-relationships
Support laravel conditional relationships.
protected function toRelationships(Request $request): array { return [ 'avatar' => AvatarResource::relationship($this->avatar), // as collection, with condition 'comments' => CommentResource::relationship(fn() => $this->whenLoaded('comments'))->asCollection(), // with relationship (allow to include links and meta on relation) 'posts' => PostResource::relationship(fn() => $this->posts) ->asCollection(), ]; }
Described attributes
@see described notation
protected function toRelationships(Request $request): array { return [ 'avatar' => $this->one(AvatarResource::class), // custom relation name 'my-avatar' => $this->one(AvatarResource::class, 'avatar'), // as collection, with condition 'comments' => $this->many(CommentResource::class) ->whenLoaded(), // with relationship (allow to include links and meta on relation) 'posts' => $this->many(PostResource::class) ->links( self: fn() => "https://api.example.com/users/{$this->resource->id}/relationships/posts", related: fn() => "https://api.example.com/users/{$this->resource->id}/posts", ) ->meta(fn() => [ 'total' => $this->integer(fn() => $this->resource->posts()->count()), ]), ]; }
Without resource class
On a resource rendering an Eloquent model, the resource class may be left out: it is guessed from the model, as
Laravel's Model::toResource() does.
#[JsonApiModel(Post::class)] // optional when the convention applies: App\Http\Resources\PostResource → App\Models\Post class PostResource extends JsonApiResource { protected function toRelationships(Request $request): array { return [ 'user' => $this->one(), // the relation `user` 'author' => $this->one('user'), // the relation `user`, under the name `author` 'comments' => $this->many(), // the relation `comments` ]; } }
A subclass of the resource keeps its model. The related model is given by the relation (Post::user()->getRelated():
a public method of the model, returning a relation when typed; the methods of Eloquent, save()…, are never called),
its resource by #[UseResource] on the related model (a JSON:API resource), otherwise by the convention
(App\Models\User → App\Http\Resources\UserResource). It is guessed once, when the definition is compiled: a
relationship that cannot be guessed (a resource rendering an array or an object that is not an Eloquent model, a
morphTo, a model without resource) is an error of the definition (php artisan jsonapi:check) and throws a
DefinitionException when rendered. Pass the resource class there, as before. A first argument without namespace is
a relation name.
Relation links and meta
@see {json:api}: relation-linkage
@see {json:api}: relation-meta
Returns links and meta for a relation.
protected function toRelationships(Request $request): array { return [ 'posts' => PostResource::relationship(fn() => $this->posts)->links(fn() => [ // links 'self' => "https://api.example.com/user/{$this->id}/relationships/posts", 'related' => "https://api.example.com/user/{$this->id}/posts", ])->meta(fn() => [ // meta 'creator' => $this->name, ]) ->asCollection(), ]; }
The self and related links can be declared by name: a string, or a closure receiving the related resource (or
collection). They win over the links of a closure given as first argument, and a link resolving to null is left
out. Unlike the links of a closure, the definition knows their names (RelationshipDefinition::$namedLinks), so tools
documenting the API can tell which links a relationship may render.
'author' => $this->one(UserResource::class)->links( self: fn() => route('posts.relationships.author', $this->resource), related: fn() => route('posts.author', $this->resource), ),
Resource identifier meta
@see {json:api}: resource-identifier-objects
The meta() of a relationship describes the relationship as a whole. Data that belongs to the link to one related
resource, such as a pivot, goes to the meta of its resource identifier: the closure receives each related resource.
protected function toRelationships(Request $request): array { return [ 'tags' => $this->many(TagResource::class) ->identifierMeta(fn(TagResource $tag) => [ 'position' => $tag->resource->pivot->position, ]), ]; }
"tags": { "data": [ { "type": "tag", "id": "3", "meta": { "position": 1 } }, { "type": "tag", "id": "7", "meta": { "position": 2 } } ] }
For a BelongsToMany, ->pivotMeta(['position']) does the same from the pivot (->pivotMeta(['position'], 'membership') for a pivot renamed with as('membership')); an attribute the pivot does not have is left out.
Relationship filters
->filters() renders only the related resources passing every rule. The filters are immutable: return them.
'posts' => $this->many(PostResource::class) ->filters(fn(Filters $filters) => $filters->can('view')->when(fn($request, $post) => $post->is_public)),
toLinks
@see {json:api}: resource-linkage
Returns resource links.
protected function toLinks(Request $request): ?array { return [ 'self' => route('api.user.show', ['id' => $this->id]), ]; }
Links by route
#[JsonApiRoutes] generates the links from the routes of the application, each route receiving the resource id:
use Ark4ne\JsonApi\Resources\Attributes\JsonApiRoutes; #[JsonApiRoutes(self: 'posts.show')] class PostResource extends JsonApiResource
links.selfof the resource object: theselfroute, which must exist (php artisan jsonapi:checkfails otherwise).links.selfandlinks.relatedof each relationship: the routesposts.relationships.{relationship}andposts.{relationship}(derived from the prefix ofself), when they exist:posts.relationships.author,posts.author. Name them withrelationship:andrelated:, orfalsefor none.- The links declared by the resource (
toLinks(),->links()) win, evennull.
The resource id is the only parameter given to the routes: give the others with URL::defaults(). A relationship
route without {relationship} in its name (relationship: 'posts.relationships', one route
posts/{post}/relationships/{relationship}) also receives the relationship name as its relationship parameter.
A relationship left out for having neither data, links nor meta (relationship.linkage) still gets its links; one
left out by fields or by its condition gets none. The routes are part of the definition
(ResourceDefinition::$routes).
toResourceMeta
@see {json:api}: resource-meta
@see {json:api}: document-meta
Returns resource meta.
protected function toResourceMeta(Request $request): ?iterable { return [ 'created_at' => $this->created_at->format(DateTimeInterface::ATOM), 'updated_at' => $this->updated_at->format(DateTimeInterface::ATOM), ]; }
toDocumentMeta
@see {json:api}: document-meta
Returns document meta (toMeta() in 1.x: a resource or a collection still declaring toMeta() throws a
DefinitionException).
protected function toDocumentMeta(Request $request): ?iterable { return [ 'copyright' => 'Copyright 2022 My Awesome Api', ]; }
Only the primary resources add document meta, never the included ones. In a collection, the first resource setting a
key keeps it, and the collection's own toDocumentMeta() wins over those of its resources:
class UserCollection extends JsonApiCollection { public $collects = UserResource::class; protected function toDocumentMeta(Request $request): ?iterable { return ['total' => fn() => $this->collection->count()]; } }
Collection
@see laravel: resource-collection
Collection are implemented in JsonApiCollection.
Usage is the same as laravel collections.
UserResource::collection(User::all()); // => JsonApiCollection
Pagination
A paginated collection keeps the output of Laravel's paginators (paginate(), simplePaginate(),
cursorPaginate()): the links first, last, prev and next the specification names (null when unavailable:
last for simplePaginate(), first and last for cursorPaginate(), prev and next at the ends), and the meta of Laravel. The specification fixes no format for the page, sort and filter parameters: handle them as in any
Laravel API (it requires a 400 Bad Request for an unsupported sort).
UserResource::collection(User::query()->paginate()); // ?page=2
Described notation
Value methods
| Method | Description |
|---|---|
bool |
Cast to boolean |
integer |
Cast to integer |
float |
Cast to float, rounded to ->precision(2), or ->truncate()d |
string |
Cast to string |
date |
Date without time, Y-m-d unless ->format(); null stays null |
datetime |
Date and time, describer.datetime unless ->format() |
time |
Time of day, H:i:s unless ->format() |
decimal |
Exact decimal as a string ("12.50"), ->scale(2); no float |
uuid |
UUID object or string, as a string |
ulid |
ULID object or string, as a string |
json |
JSON string, rendered decoded |
array |
Cast to array, supports typed arrays with ->of() |
arrayOf |
Helper method for typed arrays (alternative to array()->of()) |
mixed |
Don't cast, return as is |
enum |
Get enum value |
struct |
Custom struct. Accept an array of values |
Value types of the application
Each value type gives the definition its ValueType and a format (uuid, decimal, the PHP format of a date…), read
by the tools that document the resources. A value type of your own extends Ark4ne\JsonApi\Descriptors\Values\Value:
it casts the raw value and declares its valueType() (and valueFormat(), if any).
final class MoneyValue extends Value { public function value(mixed $of, Request $request, Configuration $configuration): string { return number_format($of / 100, 2, '.', ''); } protected function valueType(): ValueType { return ValueType::String; } protected function valueFormat(Configuration $configuration): ?string { return 'money'; } }
Register it under a name, in config/jsonapi.php (describer.types) or in a service provider:
use Ark4ne\JsonApi\Laravel\JsonApi; JsonApi::valueType('money', MoneyValue::class);
It is then written $this->money('price') in a resource (declare @method MoneyValue<mixed> money(...) for your IDE
and PHPStan), and named as the items of an array: $this->array('prices')->of('money'). A name taken by a method of
the resources (string, toArray…) is refused.
Relation methods
| Method | Description |
|---|---|
one |
For relationship with a single value: HasOne, BelongsTo, ... |
many |
For relationship with many value: HasMany, BelongsToMany, ... |
Both take the resource class, or no class on an Eloquent resource (without resource class).
Polymorphic relationships
A morphTo (or a closure giving models of several classes) renders each related model with the resource of the first
class of the map it is an instance of: list a subclass before its parent. A model of a class out of the map throws a
RenderingException; the null items of a to-many are left out.
protected function toRelationships(Request $request): array { return [ 'commentable' => $this->morph([ Post::class => PostResource::class, Video::class => VideoResource::class, ]), 'feed' => $this->morphMany([Post::class => PostResource::class, Video::class => VideoResource::class], 'items'), ]; }
include=commentable.author is valid when one of the related resources has author, path by path:
include=commentable.author,commentable.channel is valid when posts have author and videos channel.
Native resources
A relationship may relate resources of Laravel's native JSON:API (Illuminate\Http\Resources\JsonApi\JsonApiResource):
protected function toRelationships(Request $request): array { return [ 'author' => $this->one(NativeUserResource::class), 'tags' => $this->many(NativeTagResource::class), ]; }
A collection collecting native resources stands for them, and a native resource is guessed as any other
(without resource class: #[UseResource], then the naming convention). Their linkage is
rendered and loaded in batch as any other; links(), meta() and identifierMeta() receive the native resource.
Included, they are rendered as leaves by their own resolve(), with their fields[type]: their relationships are
never rendered, nothing is loaded nor included under them, and the Includes rule rejects include=author.posts.
Their fields are unknown without a model: the Fields rule accepts those of their type as they are, the type being
read without model (their toType(), otherwise Laravel's default). When their toType() needs a model, it accepts a
type no definition has. A polymorphic relationship (morph()) cannot render native resources.
Linkage and loading
@see {json:api}: resource linkage
A relationship that is not included still renders its resource linkage (data), as 1.x did. The relations a document
renders are loaded before rendering, in batch on the whole collection: one query per relation and per include
level, never one per resource.
- A relationship read from a relation (
$this->one(UserResource::class),$this->many(CommentResource::class, 'comments')) loads it.->withLoad(false)disables it;->withLoad('comments.user')or->withLoad(['comments' => fn($query) => $query->latest()])loads other paths, or constrains them. A constraint applies to the linkage and to the included resources alike. - A relationship read by a closure (
PostResource::relationship(fn() => $this->resource->posts),$this->one(…, fn() => …)) loads only whatwithLoad()declares. - An included relationship loads every path of its
withLoad(); a relationship that is not included only those reaching its related resources (commentsof['comments', 'comments.user']). - A relation already loaded (
$post->load('comments')) is not loaded again. Resources that are not Eloquent models (arrays, objects) are read as they are.
relationship.linkage says when a relationship that is not included renders its data (its links and meta are
rendered in every case):
| Value | Not included, not loaded | Not included, loaded | Included |
|---|---|---|---|
always (default) |
data, loaded in batch |
data |
data and included |
loaded |
no data |
data |
data and included |
included |
no data, the relation is not read |
no data |
data and included |
One relationship sets its own:
protected function toRelationships(Request $request): array { return [ // `data` only when included 'posts' => $this->many(PostResource::class)->whenIncluded(), // rendered only when loaded; loaded when included 'comments' => $this->many(CommentResource::class)->whenLoaded(), // or explicitly (Ark4ne\JsonApi\Configuration\Linkage) 'author' => $this->one(UserResource::class)->linkage(Linkage::Always), ]; }
protected ?bool $autoWhenIncluded = true; on a resource applies whenIncluded() to all its relationships.
Server-side include
A resource or a collection includes relationship paths whatever the request, in addition to its include:
return UserResource::make($user)->include('posts.comments');
These paths are neither validated by Rules\Includes nor limited by include.max-depth.
Enum
Method enum allow to get enum value for backed enum or name for unit enum.
According to structure:
/// Role.php enum Role { case ADMIN; case USER; } /// State.php enum State:int { case ACTIVE = 1; case INACTIVE = 0; } /// User.php class User extends Model { $casts = [ 'role' => Role::class, 'state' => State::class, ]; }
The following attributes resource:
// UserResource.php public function toAttributes(Request $request): array { return [ 'status' => $this->enum(), 'role' => $this->enum(), ]; }
Will return:
[
"status": 1,
"role": "ADMIN"
]
Typed Arrays
The array descriptor supports typed arrays to ensure all elements are cast to a specific type. This is useful when you need to guarantee type consistency across array elements.
Basic Usage
// UserResource.php public function toAttributes(Request $request): array { return [ // Array of strings - all values will be cast to string 'tags' => $this->array('tags')->of($this->string()), // Array of integers - all values will be cast to integer 'scores' => $this->array('scores')->of($this->integer()), // Array of floats 'prices' => $this->array('prices')->of($this->float()), // Array of booleans 'flags' => $this->array('flags')->of($this->bool()), ]; }
Using Class References
You can also use class references, or the name of a value type (->of('integer'), ->of('money')), instead of
descriptor instances. A struct needs its closure: give it as an instance (->of($this->struct(fn() => [...]))).
use Ark4ne\JsonApi\Descriptors\Values\ValueString; use Ark4ne\JsonApi\Descriptors\Values\ValueInteger; public function toAttributes(Request $request): array { return [ 'tags' => $this->array('tags')->of(ValueString::class), 'scores' => $this->array('scores')->of(ValueInteger::class), ]; }
Alternative Syntax
You can also use the arrayOf() helper method:
public function toAttributes(Request $request): array { return [ 'tags' => $this->arrayOf($this->string(), 'tags'), 'scores' => $this->arrayOf($this->integer(), 'scores'), ]; }
Nested Typed Arrays
For multi-dimensional arrays, you can nest array()->of() calls:
public function toAttributes(Request $request): array { return [ // 2D array (matrix) of integers 'matrix' => $this->array('matrix')->of( $this->array()->of($this->integer()) ), ]; }
With Closures and Transformations
Combine typed arrays with closures for data transformation:
public function toAttributes(Request $request): array { return [ // Transform and type cast 'doubled' => $this->array(fn() => array_map(fn($n) => $n * 2, $this->numbers)) ->of($this->integer()), // Access nested properties 'user_ids' => $this->array(fn() => $this->users->pluck('id')) ->of($this->integer()), ]; }
With Conditions
Typed arrays support all conditional methods:
public function toAttributes(Request $request): array { return [ // Only include if not null 'tags' => $this->array('tags')->of($this->string())->whenNotNull(), // Only include if array is not empty 'scores' => $this->array('scores')->of($this->integer())->whenFilled(), // Conditional based on closure 'admin_notes' => $this->array('notes')->of($this->string()) ->when(fn() => $request->user()->isAdmin()), ]; }
⚠️ Important Note: Conditions applied to the item type (inside
of()) are not evaluated per-item. They apply to the entire array descriptor, not individual elements.// ❌ This will NOT filter individual items 'even-numbers' => $this->array('numbers')->of( $this->integer()->when(fn($request, $model, $attr) => $attr % 2 === 0) ) // All items will be included, the when() doesn't filter per item // ✅ To filter items, do it before passing to the array 'even-numbers' => $this->array( fn() => array_filter($this->numbers, fn($n) => $n % 2 === 0) )->of($this->integer())
Example
Given a model with mixed-type arrays:
$user = new User([ 'tags' => ['php', 'laravel', 123, true], 'scores' => [95.5, '87', 92, '78.9'], ]);
The resource will ensure type consistency:
public function toAttributes(Request $request): array { return [ 'tags' => $this->array('tags')->of($this->string()), 'scores' => $this->array('scores')->of($this->integer()), ]; }
Output:
{
"tags": ["php", "laravel", "123", "1"],
"scores": [95, 87, 92, 78]
}