Laravel 13 JSON:API Resources: Spec-Compliant APIs Without a Package
Laravel 13 ships first-party JSON:API resources. Learn how JsonApiResource handles attributes, relationships, sparse fieldsets, and includes.
For years, building a JSON:API compliant backend in Laravel meant reaching for a third-party package or hand-rolling the envelope structure yourself. Every team I have worked with eventually wrote the same boilerplate: a type key here, an attributes block there, an included array assembled with a custom collector. Laravel 13, released in March 2026, finally makes that boilerplate disappear. The framework now ships JsonApiResource, a first-party resource class that produces responses compliant with the JSON:API specification out of the box.
If you build APIs consumed by Ember, React Query with JSON:API adapters, or any client that expects the spec, this is one of the most practical additions in the Laravel 13 release. Here is how it works and where it fits.
What You Get
JsonApiResource extends the familiar JsonResource class, so everything you already know about API resources still applies. On top of that, it automatically handles the resource object structure (type, id, attributes, relationships), opt-in relationship includes, sparse fieldsets, lazy attribute evaluation, and it sets the Content-Type header to application/vnd.api+json like the spec requires.
Generating one is a single flag on the Artisan command you already use:
php artisan make:resource PostResource --json-api
The generated class gives you two properties to fill in:
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\JsonApi\JsonApiResource;
class PostResource extends JsonApiResource
{
/**
* The resource's attributes.
*/
public $attributes = [
'title',
'body',
'created_at',
];
/**
* The resource's relationships.
*/
public $relationships = [
'author',
'comments',
];
}
That is the whole class. Returning it from a route works exactly like a standard resource, including the toResource convenience method:
Route::get('/api/posts/{post}', function (Post $post) {
return $post->toResource();
});
The response comes back spec-compliant without any manual array building:
{
"data": {
"id": "1",
"type": "posts",
"attributes": {
"title": "Hello World",
"body": "This is my first post."
}
}
}
The type is derived from the class name. PostResource becomes posts, and BlogPostResource becomes blog-posts. If you need something different, override toType and toId. That is handy when an AuthorResource wraps a User model but should serialize as authors.
Relationships Are Opt-In, and That Is the Right Call
This is my favorite design decision in the whole feature. Relationships listed in $relationships are not serialized unless the client asks for them with the include query parameter:
GET /api/posts/1?include=author,comments
Only then does the response gain a relationships block with resource identifier objects and a top-level included array containing the full related resources. Nested includes work with dot notation (?include=comments.author), and there is a configurable depth limit you can adjust in a service provider:
use Illuminate\Http\Resources\JsonApi\JsonApiResource;
JsonApiResource::maxRelationshipDepth(3);
Why does opt-in matter? Because the classic failure mode of hand-built APIs is over-fetching. Someone adds a relationship to a resource for one screen, and suddenly every endpoint that touches that resource is dragging along data nobody asked for, plus the queries to load it. Making the client declare what it needs keeps payloads lean by default.
For more control, override toRelationships and return closures. The closure only runs when the relationship is actually requested, so you can put authorization logic right in the resource without paying for it on every request:
public function toRelationships(Request $request): array
{
return [
'author' => UserResource::class,
'comments' => fn () => CommentResource::collection(
$request->user()->is($this->resource)
? $this->comments
: $this->comments->where('is_public', true),
),
];
}
Sparse Fieldsets for Free
Sparse fieldsets are the other half of the payload-size story. Clients can request only the attributes they need, per resource type:
GET /api/posts?fields[posts]=title,created_at&fields[users]=name
You write nothing to support this. The resource filters itself. If a particular endpoint should ignore the query string, chain ignoreFieldsAndIncludesInQueryString() onto the resource. And if you have already eager-loaded relationships in the controller and want them included regardless of the query string, includePreviouslyLoadedRelationships() does exactly that.
Expensive attributes can be deferred with closures inside toAttributes, so a costly computed field is only evaluated when a response actually includes it:
public function toAttributes(Request $request): array
{
return [
'title' => $this->title,
'is_published' => fn () => $this->published_at !== null,
'word_count' => fn () => str_word_count(strip_tags($this->body)),
];
}
Links, Meta, and the Missing Piece
Per-resource links and meta are a pair of method overrides, toLinks and toMeta, and they land in the right place in the resource object automatically. A self link via route('api.posts.show', $this->resource) is about as much ceremony as it gets.
One honest caveat: this feature handles serialization, the response side. It does not parse incoming JSON:API query parameters like filter and sort. The Laravel docs themselves point to Spatie’s Laravel Query Builder as the companion for the request side, and that pairing works well in practice. Query Builder reads the filters and sorts, Eloquent loads the data, and JsonApiResource shapes the output.
Should You Migrate?
If you are already using the excellent Laravel JSON:API package, there is no urgent reason to rip it out. It covers server-side request parsing, validation, and more surface area than the first-party feature does today.
But for new Laravel 13 projects, the calculus has changed. You get spec compliance, sparse fieldsets, and disciplined relationship loading with zero additional dependencies, using the same resource concepts your team already knows. For a typical API backing an SPA or mobile app, that is enough. Start with the first-party tooling, add Spatie’s Query Builder when you need filtering, and only reach for the bigger packages when you genuinely need their extra features.
After years of treating JSON:API support as something you bolt on, having it in the framework feels overdue. It is one less decision to make at project kickoff, and one less dependency to track through major version upgrades.