The Complete Guide to Laravel 13 JSON:API Resources
I spent years wiring up spatie/laravel-json-api or hand-rolling JSON:API-shaped responses in toArray() overrides. Every project had the same pattern: define a resource, manually build the data/attributes/relationships envelope, forget the self link, then debug a mobile client that expected strict spec compliance. Laravel 13 removes all of that friction with JsonApiResource — a first-party resource class that produces fully compliant JSON:API responses, handles relationship inclusion, sparse fieldsets, and sets the correct Content-Type header automatically. No packages. No boilerplate envelopes.
What you'll learn
- How Laravel 13's
JsonApiResourceworks and how it differs from the standardJsonResource - How to define attributes, relationships, links, and meta on a resource
- How compound documents and the
?include=parameter work - How to use sparse fieldsets to let clients control payload size
- How to implement cursor-based and page-based pagination for JSON:API collections
- How to format errors as JSON:API error objects
- How to test every aspect of your JSON:API responses with Pest
- How to migrate from
spatie/laravel-json-apito the native implementation
Why JSON:API and why now
REST APIs suffer from a consistency problem. Should relationships be embedded or linked? Are IDs strings or integers? Where do pagination links go? Every team invents its own answers, and every client has to learn the dialect. The JSON:API specification settles these questions with a single, well-documented standard: resource objects carry type and id, attributes live in attributes, related resources go in relationships with resource linkage, and compound documents land in a top-level included array.
Before Laravel 13, adopting JSON:API meant pulling in a third-party package. The most popular — laravel-json-api by Cloud Creativity, later maintained under the laravel-json-api organisation — worked well but introduced its own routing layer, schema classes, and request validation pipeline. For teams already comfortable with Laravel's resource classes and form requests, the learning curve was steep.
Laravel 13 takes a different approach. Rather than replacing the resource layer, it extends it. JsonApiResource inherits from JsonResource, which means everything you already know about conditional attributes, response customisation, and collection resources still applies. The framework handles the JSON:API envelope, the application/vnd.api+json content type, and the query-string parsing for fields and include parameters. You focus on declaring what your resource exposes.
If you're upgrading from Laravel 12, the upgrade guide covers the full migration path — JSON:API resources are one of several headline features alongside the AI SDK, passkeys, and Queue::route().
Setting up the example project
Every example in this guide builds on a single domain: a publishing platform with posts, authors (users), comments, and tags. This gives us enough relationship variety to cover has-one, has-many, many-to-many, and polymorphic scenarios.
composer create-project laravel/laravel jsonapi-demo
cd jsonapi-demo
Create the models and migrations:
php artisan make:model Post -mf
php artisan make:model Comment -mf
php artisan make:model Tag -mf
Here are the migrations you'll need. The users table ships with Laravel, so we only need posts, comments, tags, and the pivot:
// database/migrations/xxxx_create_posts_table.php
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->string('slug')->unique();
$table->text('body');
$table->timestamp('published_at')->nullable();
$table->timestamps();
});
// database/migrations/xxxx_create_comments_table.php
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->text('body');
$table->timestamps();
});
// database/migrations/xxxx_create_tags_table.php
Schema::create('tags', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->timestamps();
});
// database/migrations/xxxx_create_post_tag_table.php
Schema::create('post_tag', function (Blueprint $table) {
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
$table->foreignId('tag_id')->constrained()->cascadeOnDelete();
$table->primary(['post_id', 'tag_id']);
});
Set up the model relationships:
// app/Models/Post.php
class Post extends Model
{
use HasFactory;
protected $fillable = ['user_id', 'title', 'slug', 'body', 'published_at'];
protected function casts(): array
{
return [
'published_at' => 'datetime',
];
}
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class);
}
}
Run the migrations:
php artisan migrate
Creating your first JSON:API resource
Generate a JSON:API resource with the --json-api flag:
php artisan make:resource PostResource --json-api
This creates a class extending Illuminate\Http\Resources\JsonApi\JsonApiResource instead of the standard JsonResource. The simplest version declares attributes and relationships as properties:
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\JsonApi\JsonApiResource;
class PostResource extends JsonApiResource
{
public $attributes = [
'title',
'slug',
'body',
'published_at',
'created_at',
'updated_at',
];
public $relationships = [
'author',
'comments',
'tags',
];
}
Wire it to a route:
// routes/api.php
use App\Http\Resources\PostResource;
use App\Models\Post;
Route::get('/posts/{post}', function (Post $post) {
return new PostResource($post);
});
Hit GET /api/posts/1 and you get a fully compliant JSON:API response:
{
"data": {
"id": "1",
"type": "posts",
"attributes": {
"title": "Getting Started with JSON:API",
"slug": "getting-started-with-json-api",
"body": "This is the post body...",
"published_at": "2026-04-17T10:00:00.000000Z",
"created_at": "2026-04-17T10:00:00.000000Z",
"updated_at": "2026-04-17T10:00:00.000000Z"
},
"relationships": {
"author": {
"data": { "id": "1", "type": "users" }
},
"comments": {
"data": [
{ "id": "1", "type": "comments" },
{ "id": "2", "type": "comments" }
]
},
"tags": {
"data": [
{ "id": "1", "type": "tags" }
]
}
}
}
}
Notice what you didn't have to do: build the data envelope, stringify the ID, derive the type, or structure the relationship linkage. The framework handles all of it.
Customising attributes with toAttributes
The property-based approach works for straightforward mappings, but production resources usually need computed values or conditional logic. Override toAttributes() for full control:
use Illuminate\Http\Request;
public function toAttributes(Request $request): array
{
return [
'title' => $this->title,
'slug' => $this->slug,
'body' => $this->body,
'excerpt' => fn () => str($this->body)->limit(200)->toString(),
'is_published' => fn () => $this->published_at !== null,
'published_at' => $this->published_at,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
Wrapping values in closures is a performance detail worth understanding. When a client requests sparse fieldsets — say ?fields[posts]=title,slug — Laravel skips evaluating any closure that isn't in the requested field list. If excerpt involves an expensive string operation or is_published triggers a database query, the closure ensures you only pay for fields the client actually asked for.
Relationships: has-one, has-many, many-to-many, and polymorphic
Simple relationship declarations
The $relationships property handles the common case:
public $relationships = [
'author',
'comments',
'tags',
];
Laravel infers the resource class from the relationship name. author maps to UserResource, comments to CommentResource, and tags to TagResource. If your resource class doesn't follow the convention, specify it explicitly:
public $relationships = [
'author' => UserResource::class,
'comments' => CommentResource::class,
'tags' => TagResource::class,
];
Conditional relationship logic with toRelationships
For relationships that depend on the request context — permissions, feature flags, or visibility rules — override toRelationships():
public function toRelationships(Request $request): array
{
return [
'author' => UserResource::class,
'comments' => fn () => CommentResource::collection(
$request->user()?->is($this->author)
? $this->comments
: $this->comments->where('is_public', true),
),
'tags' => TagResource::class,
];
}
This pattern gives the post author access to all comments (including private ones) while limiting other users to public comments only.
Many-to-many relationships
The tags relationship is a BelongsToMany on the model. JSON:API treats it identically to a HasMany — the response contains an array of resource identifier objects:
"tags": {
"data": [
{ "id": "1", "type": "tags" },
{ "id": "3", "type": "tags" }
]
}
No special configuration is needed. Laravel reads the relationship from the model and serialises the linkage array automatically.
Polymorphic relationships
If your comments are polymorphic (commentable on posts, videos, or pages), define the relationship on the model as usual:
// app/Models/Comment.php
public function commentable(): MorphTo
{
return $this->morphTo();
}
In the CommentResource, declare the relationship and Laravel resolves the correct resource class based on the morph type:
// app/Http/Resources/CommentResource.php
class CommentResource extends JsonApiResource
{
public $attributes = ['body', 'created_at'];
public $relationships = ['author', 'commentable'];
}
The response includes the polymorphic type information in the resource identifier:
"commentable": {
"data": { "id": "1", "type": "posts" }
}
Generate the remaining resources to round out the example:
php artisan make:resource UserResource --json-api
php artisan make:resource CommentResource --json-api
php artisan make:resource TagResource --json-api
// app/Http/Resources/UserResource.php
class UserResource extends JsonApiResource
{
public $attributes = ['name', 'email', 'created_at'];
public $relationships = ['posts'];
}
// app/Http/Resources/TagResource.php
class TagResource extends JsonApiResource
{
public $attributes = ['name'];
public $relationships = ['posts'];
}
Compound documents and the include parameter
Compound documents are one of JSON:API's most useful features. Instead of forcing clients to make N+1 requests for related data, a single request with ?include= returns everything in one payload.
Basic inclusion
GET /api/posts/1?include=author,comments
The response embeds the full resource objects in a top-level included array:
{
"data": {
"id": "1",
"type": "posts",
"attributes": { "title": "..." },
"relationships": {
"author": { "data": { "id": "1", "type": "users" } },
"comments": {
"data": [
{ "id": "1", "type": "comments" }
]
}
}
},
"included": [
{
"id": "1",
"type": "users",
"attributes": { "name": "Steven Richardson" }
},
{
"id": "1",
"type": "comments",
"attributes": { "body": "Great article!" }
}
]
}
Nested includes with dot notation
Clients can traverse relationship chains using dots:
GET /api/posts/1?include=comments.author
This includes the comments and the author of each comment — useful for rendering a comment thread with user avatars without additional requests.
Controlling include depth
Nested includes open the door to expensive queries if left unchecked. Configure a global maximum depth in your AppServiceProvider:
use Illuminate\Http\Resources\JsonApi\JsonApiResource;
public function boot(): void
{
JsonApiResource::maxRelationshipDepth(3);
}
With a depth of 3, a request like ?include=comments.author.posts.tags would be truncated at the third level. Choose a value that balances client flexibility with query performance — I've found 3 to be a sensible default for most applications.
Eager loading for performance
The ?include parameter tells Laravel which relationships to serialise, but you still need to eager-load them on the Eloquent query to avoid N+1 problems. Build your controller to parse the include parameter:
// app/Http/Controllers/PostController.php
use App\Http\Resources\PostResource;
use App\Models\Post;
use Illuminate\Http\Request;
class PostController extends Controller
{
public function show(Request $request, Post $post): PostResource
{
$includes = explode(',', $request->query('include', ''));
$allowed = ['author', 'comments', 'comments.author', 'tags'];
$eagerLoad = array_intersect($includes, $allowed);
return new PostResource($post->load($eagerLoad));
}
public function index(Request $request): \Illuminate\Http\Resources\Json\AnonymousResourceCollection
{
$includes = explode(',', $request->query('include', ''));
$allowed = ['author', 'comments', 'tags'];
$eagerLoad = array_intersect($includes, $allowed);
$posts = Post::with($eagerLoad)
->latest('published_at')
->paginate();
return PostResource::collection($posts);
}
}
Whitelisting allowed includes is critical. Without it, a malicious client could request ?include=author.posts.comments.author.posts and generate a cascade of queries. The whitelist acts as a server-side safety net alongside maxRelationshipDepth.
Including previously loaded relationships
If your model arrives with relationships already eager-loaded (from a service layer or scope), you can tell the resource to include them all without parsing the query string:
$post = Post::with(['author', 'comments', 'tags'])->findOrFail($id);
return $post->toResource()->includePreviouslyLoadedRelationships();
This is convenient for internal API consumers where the server controls what's included.
Sparse fieldsets
Sparse fieldsets let clients request only the attributes they need, reducing payload size and potentially query cost when combined with closure-wrapped attributes.
Client-side usage
GET /api/posts?fields[posts]=title,slug&fields[users]=name
The response strips every attribute not in the field list for that resource type:
{
"data": [
{
"id": "1",
"type": "posts",
"attributes": {
"title": "Getting Started with JSON:API",
"slug": "getting-started-with-json-api"
}
}
]
}
How it works under the hood
JsonApiResource reads the fields query parameter and filters the output of toAttributes() before serialisation. If an attribute value is a closure and the field isn't requested, the closure never executes. This is why I recommend wrapping any expensive computed attribute in a closure — it's free when not requested and evaluated on demand when it is.
Disabling sparse fieldsets
Some endpoints (webhooks, internal APIs) shouldn't respect client field requests. Disable parsing with:
return $post->toResource()->ignoreFieldsAndIncludesInQueryString();
This returns the full resource regardless of query parameters.
Common pitfall: sparse fieldsets and relationships
A subtlety that catches people: if a client requests ?fields[posts]=title and omits the relationship field, the relationship linkage is still included in the response. Sparse fieldsets in JSON:API only filter the attributes object, not the relationships object. The spec requires this — clients need the linkage data to resolve included resources. I've seen developers file bug reports over this behaviour, but it's by design.
Links and meta
Self links
Every JSON:API resource should include a self link pointing to its canonical URL. Override toLinks():
public function toLinks(Request $request): array
{
return [
'self' => route('api.posts.show', $this->resource),
];
}
Custom meta
Add computed metadata that doesn't belong in attributes:
public function toMeta(Request $request): array
{
return [
'readable_published_at' => $this->published_at?->diffForHumans(),
'word_count' => str_word_count($this->body),
];
}
Resource type and ID customisation
By default, the resource type derives from the class name (PostResource becomes posts) and the ID comes from the model's primary key. Override either when your API conventions differ:
public function toType(Request $request): string
{
return 'articles'; // instead of 'posts'
}
public function toId(Request $request): string
{
return (string) $this->uuid; // use UUID instead of auto-increment
}
Pagination
JSON:API defines pagination links as first, last, prev, and next in the top-level links object. Laravel's paginator integrates with this automatically when you return a collection from a paginated query.
Page-based pagination
// Controller
$posts = Post::with('author')->paginate(15);
return PostResource::collection($posts);
The response includes spec-compliant pagination links:
{
"data": [ ... ],
"links": {
"first": "https://example.com/api/posts?page=1",
"last": "https://example.com/api/posts?page=5",
"prev": null,
"next": "https://example.com/api/posts?page=2"
},
"meta": {
"current_page": 1,
"last_page": 5,
"per_page": 15,
"total": 72
}
}
Cursor-based pagination
For large datasets or real-time feeds, cursor pagination avoids the "page drift" problem where new records shift items between pages. Laravel supports cursor pagination natively:
$posts = Post::with('author')
->orderBy('published_at', 'desc')
->orderBy('id', 'desc')
->cursorPaginate(15);
return PostResource::collection($posts);
The response uses opaque cursor tokens instead of page numbers:
{
"data": [ ... ],
"links": {
"first": "https://example.com/api/posts?cursor=",
"next": "https://example.com/api/posts?cursor=eyJwdWJsaXNoZWRfYXQiOi...",
"prev": null
},
"meta": {
"per_page": 15,
"next_cursor": "eyJwdWJsaXNoZWRfYXQiOi...",
"prev_cursor": null
}
}
Cursor pagination requires a deterministic sort order. Always include the primary key as a tiebreaker (as shown with orderBy('id', 'desc')) to guarantee stable cursor positions. If you're building an API that powers infinite scroll or real-time feeds, cursor pagination is the right choice. For admin dashboards where users jump to arbitrary pages, stick with page-based.
Sorting and filtering with form requests
The JSON:API spec reserves the sort and filter query parameters but leaves implementation details to the server. Here's a clean pattern using form requests that protects your API from arbitrary query injection.
Sort parameter
GET /api/posts?sort=-published_at,title
A leading hyphen means descending. Create a form request that validates and applies sorts:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class PostIndexRequest extends FormRequest
{
private array $sortable = ['title', 'published_at', 'created_at'];
public function rules(): array
{
return [
'sort' => ['nullable', 'string'],
'filter.published' => ['nullable', 'boolean'],
'filter.author' => ['nullable', 'integer', 'exists:users,id'],
'fields.*' => ['nullable', 'string'],
'include' => ['nullable', 'string'],
'page' => ['nullable', 'integer', 'min:1'],
];
}
public function applySorts($query)
{
$sort = $this->query('sort', '-published_at');
foreach (explode(',', $sort) as $field) {
$direction = str_starts_with($field, '-') ? 'desc' : 'asc';
$column = ltrim($field, '-');
if (in_array($column, $this->sortable, true)) {
$query->orderBy($column, $direction);
}
}
return $query;
}
public function applyFilters($query)
{
$filters = $this->query('filter', []);
if (isset($filters['published'])) {
$query->when(
$filters['published'],
fn ($q) => $q->whereNotNull('published_at'),
fn ($q) => $q->whereNull('published_at'),
);
}
if (isset($filters['author'])) {
$query->where('user_id', $filters['author']);
}
return $query;
}
}
Use it in the controller:
public function index(PostIndexRequest $request): \Illuminate\Http\Resources\Json\AnonymousResourceCollection
{
$query = Post::query();
$request->applySorts($query);
$request->applyFilters($query);
$includes = array_intersect(
explode(',', $request->query('include', '')),
['author', 'comments', 'tags'],
);
$posts = $query->with($includes)->paginate(15);
return PostResource::collection($posts);
}
This approach keeps filtering logic testable and isolated from the controller. Each filter validates its input through the form request rules before the query method runs. If you're building APIs that need rate limiting on these routes, the form request validation runs before your database queries — failing fast on invalid input.
Error objects and exception handling
The JSON:API spec defines a specific error format. Instead of returning Laravel's default exception JSON, you want errors structured as:
{
"errors": [
{
"status": "422",
"title": "Validation Error",
"detail": "The title field is required.",
"source": {
"pointer": "/data/attributes/title"
}
}
]
}
Custom exception handler
Add JSON:API error formatting in your exception handler's render method or a dedicated middleware:
<?php
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Throwable;
class Handler extends ExceptionHandler
{
public function render($request, Throwable $e): \Symfony\Component\HttpFoundation\Response
{
if ($request->is('api/*') && $request->wantsJson()) {
return $this->renderJsonApiError($request, $e);
}
return parent::render($request, $e);
}
private function renderJsonApiError(Request $request, Throwable $e): JsonResponse
{
if ($e instanceof ValidationException) {
$errors = collect($e->errors())->flatMap(function ($messages, $field) {
return array_map(fn ($message) => [
'status' => '422',
'title' => 'Validation Error',
'detail' => $message,
'source' => [
'pointer' => '/data/attributes/' . str_replace('.', '/', $field),
],
], $messages);
})->values()->all();
return response()->json(['errors' => $errors], 422)
->header('Content-Type', 'application/vnd.api+json');
}
$status = $e instanceof HttpException ? $e->getStatusCode() : 500;
$error = [
'status' => (string) $status,
'title' => match (true) {
$e instanceof HttpException => $e->getMessage() ?: 'Error',
default => 'Internal Server Error',
},
];
if (config('app.debug')) {
$error['detail'] = $e->getMessage();
$error['meta'] = [
'exception' => get_class($e),
'file' => $e->getFile(),
'line' => $e->getLine(),
];
}
return response()->json(['errors' => [$error]], $status)
->header('Content-Type', 'application/vnd.api+json');
}
}
Model not found errors
Laravel throws a ModelNotFoundException when route model binding fails. Register a mapping in your handler to return a clean 404:
use Illuminate\Database\Eloquent\ModelNotFoundException;
// In the renderJsonApiError method:
if ($e instanceof ModelNotFoundException) {
return response()->json([
'errors' => [[
'status' => '404',
'title' => 'Not Found',
'detail' => 'The requested resource does not exist.',
]],
], 404)->header('Content-Type', 'application/vnd.api+json');
}
Advanced patterns and edge cases
Conditional attributes based on user permissions
Not every attribute should be visible to every consumer. Use closures with permission checks:
public function toAttributes(Request $request): array
{
return [
'title' => $this->title,
'body' => $this->body,
'internal_notes' => fn () => $request->user()?->can('viewInternalNotes', $this->resource)
? $this->internal_notes
: null,
'revenue' => fn () => $request->user()?->is($this->author)
? $this->revenue
: null,
];
}
Because these are closures, the permission check only runs when the field is actually included in the response — either because the client requested it via sparse fieldsets or because no fieldset filter was applied.
Resource collections with top-level meta
When returning a collection, you often want to include aggregate metadata. Use a custom resource collection:
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class PostCollection extends ResourceCollection
{
public $collects = PostResource::class;
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
];
}
public function with(Request $request): array
{
return [
'meta' => [
'total_published' => $this->collection->filter(
fn ($post) => $post->published_at !== null
)->count(),
],
];
}
}
Using PHP backed enums in attributes
If your models use PHP backed enums for status fields, they serialise cleanly in JSON:API attributes. Define the enum:
enum PostStatus: string
{
case Draft = 'draft';
case Published = 'published';
case Archived = 'archived';
}
Cast it on the model and expose it in the resource:
// In Post model casts()
'status' => PostStatus::class,
// In PostResource toAttributes()
'status' => $this->status->value,
'is_published' => fn () => $this->status === PostStatus::Published,
Immutable response objects
For APIs that return value objects or DTOs, you can create a JsonApiResource from any object — it doesn't have to be an Eloquent model. Override toId() and toType() to provide the required fields:
class MetricResource extends JsonApiResource
{
public function toType(Request $request): string
{
return 'metrics';
}
public function toId(Request $request): string
{
return $this->resource->key;
}
public function toAttributes(Request $request): array
{
return [
'key' => $this->resource->key,
'value' => $this->resource->value,
'recorded_at' => $this->resource->recordedAt->toIso8601String(),
];
}
}
Testing JSON:API responses with Pest
I've used Pest for testing since it became the default in Laravel 11, and architecture tests are a key part of maintaining consistency. JSON:API responses need targeted assertions for the spec-specific structure.
Testing resource structure
use App\Models\Post;
use App\Models\User;
it('returns a valid JSON:API resource for a single post', function () {
$user = User::factory()->create();
$post = Post::factory()->for($user, 'author')->create();
$response = $this->getJson("/api/posts/{$post->id}");
$response
->assertOk()
->assertHeader('Content-Type', 'application/vnd.api+json')
->assertJsonStructure([
'data' => [
'id',
'type',
'attributes' => ['title', 'slug', 'body'],
'relationships' => ['author', 'comments', 'tags'],
],
])
->assertJsonPath('data.type', 'posts')
->assertJsonPath('data.id', (string) $post->id);
});
Testing compound documents
it('includes related resources when requested', function () {
$post = Post::factory()
->has(User::factory(), 'author')
->has(\App\Models\Comment::factory()->count(2))
->create();
$response = $this->getJson("/api/posts/{$post->id}?include=author,comments");
$response
->assertOk()
->assertJsonCount(3, 'included') // 1 author + 2 comments
->assertJsonPath('included.0.type', 'users');
});
Testing sparse fieldsets
it('respects sparse fieldsets', function () {
$post = Post::factory()->create();
$response = $this->getJson("/api/posts/{$post->id}?fields[posts]=title,slug");
$response->assertOk();
$attributes = $response->json('data.attributes');
expect($attributes)->toHaveKeys(['title', 'slug'])
->and($attributes)->not->toHaveKey('body')
->and($attributes)->not->toHaveKey('created_at');
});
Testing error responses
it('returns JSON:API error format for validation failures', function () {
$response = $this->postJson('/api/posts', []);
$response
->assertStatus(422)
->assertJsonStructure([
'errors' => [
['status', 'title', 'detail', 'source' => ['pointer']],
],
])
->assertJsonPath('errors.0.status', '422');
});
it('returns a 404 JSON:API error for missing resources', function () {
$response = $this->getJson('/api/posts/99999');
$response
->assertNotFound()
->assertJsonPath('errors.0.status', '404')
->assertJsonPath('errors.0.title', 'Not Found');
});
Testing pagination
it('paginates post collections with JSON:API links', function () {
Post::factory()->count(25)->create();
$response = $this->getJson('/api/posts?page=1');
$response
->assertOk()
->assertJsonCount(15, 'data')
->assertJsonStructure([
'links' => ['first', 'last', 'prev', 'next'],
'meta' => ['current_page', 'last_page', 'per_page', 'total'],
])
->assertJsonPath('meta.total', 25)
->assertJsonPath('meta.current_page', 1);
});
Common mistakes
Forgetting to eager-load included relationships. The ?include parameter tells the serialiser what to include, but it doesn't trigger eager loading. If you don't call ->load() or ::with() in your controller, you'll get N+1 queries. Laravel won't warn you — your response will be correct but slow.
Returning standard JsonResource instead of JsonApiResource. If you forget the --json-api flag during generation and extend JsonResource, your responses will use the standard Laravel envelope (data wrapping) instead of the JSON:API structure. The subtle difference means your mobile client's JSON:API parser will choke on the response.
Not validating include and sort parameters. The spec says a server should return 400 Bad Request for unsupported parameters. If you blindly pass ?include= values into ->load(), a typo or malicious input could throw an Eloquent exception. Always whitelist.
Assuming sparse fieldsets filter relationships. As covered earlier, ?fields[posts]=title only filters attributes, not relationships. The relationship linkage is always present. If you want to exclude relationship data entirely, don't declare it in $relationships.
Stringifying IDs inconsistently. The JSON:API spec requires id to be a string. JsonApiResource handles this automatically, but if you're building resource identifiers manually in tests or client code, remember to compare strings, not integers.
Migrating from spatie/laravel-json-api
If you're currently using the laravel-json-api/laravel package (originally by Cloud Creativity, later maintained by the community), migration to native JsonApiResource is straightforward but methodical.
The key differences: the package uses Schema classes to define your API; Laravel 13 uses Resource classes. The package handles routing, validation, and query building; Laravel 13 leaves those to standard Laravel controllers and form requests.
A practical migration path:
- Generate
JsonApiResourceclasses for each of your existing schemas - Move attribute definitions from
Schema::attributes()to$attributesortoAttributes() - Move relationship definitions to
$relationshipsortoRelationships() - Replace the package's custom routes with standard
Route::apiResource()definitions - Move filter and sort logic from schema classes to form requests
- Update your tests to assert against the new controller endpoints
The responses should be structurally identical — both produce spec-compliant JSON:API. Run your existing API tests after each model migration to verify parity. If your frontend uses a JSON:API client library like kitql or ember-data, the switch should be transparent.
Wrapping up
Laravel 13's JsonApiResource does what the framework does best — it takes a pattern the community has been implementing with packages for years and absorbs it into the core with a clean, minimal API. You declare attributes and relationships, and the framework handles the spec-compliant envelope, content type, sparse fieldsets, and compound documents.
We covered the full surface area here: generating resources, defining attributes with lazy evaluation, wiring up every relationship type, parsing includes with eager loading, paginating with cursors, formatting errors, and testing the lot with Pest. The code examples build on a single domain so you can follow along in a real project.
If you're upgrading from Laravel 12, check the upgrade guide for the full migration path. If you're also adopting the new PHP attributes syntax across your models, jobs, and commands, it pairs well with the declarative style of JsonApiResource. And if your API serves AI clients alongside human ones, the Laravel AI SDK guide covers the other major Laravel 13 feature worth investing in.
FAQ
What is JSON:API in Laravel 13?
JSON:API in Laravel 13 refers to the first-party JsonApiResource class that ships with the framework. It extends the existing JsonResource and produces responses that comply with the JSON:API specification — including properly structured resource objects with type, id, attributes, and relationships, plus automatic application/vnd.api+json content type headers. You generate one with php artisan make:resource PostResource --json-api.
How to use sparse fieldsets in Laravel?
Sparse fieldsets work automatically with JsonApiResource. Clients add ?fields[type]=field1,field2 to the query string — for example, ?fields[posts]=title,slug&fields[users]=name. Laravel reads the fields parameter and filters the attributes object in the response. Attribute values wrapped in closures are never evaluated if the field isn't requested, saving computation on expensive derived values.
How to include relationships in Laravel JSON:API?
Define relationships in your resource's $relationships property or toRelationships() method. Clients request them with the ?include= query parameter — for example, ?include=author,comments. Nested relationships use dot notation: ?include=comments.author. Remember to eager-load the requested relationships in your controller with ->load() or ::with() to avoid N+1 queries.
How to migrate from spatie/laravel-json-api?
Replace each Schema class with a JsonApiResource, move attribute and relationship definitions into the resource properties or methods, swap the package's custom routing for standard Laravel routes, and move filtering/sorting logic into form requests. The output format is the same JSON:API structure, so frontend clients won't need changes. Migrate one resource at a time and run your API tests after each to verify parity.
How to test JSON:API responses with Pest?
Use Laravel's HTTP testing methods with JSON path assertions. Assert the Content-Type header is application/vnd.api+json, verify the data.type and data.id fields, check data.attributes contains expected keys, and use assertJsonCount on the included array for compound documents. For error responses, assert the errors array structure with status, title, detail, and source.pointer fields.
What is a compound document in JSON:API?
A compound document is a JSON:API response that includes related resources alongside the primary data. When a client requests ?include=author,comments, the related resource objects appear in a top-level included array. Each included resource has its own type, id, and attributes, and is linked from the primary resource's relationships object via resource identifier objects. This eliminates the need for multiple requests to fetch related data.
How to implement cursor pagination in JSON:API?
Use Laravel's cursorPaginate() method instead of paginate() in your controller query. Ensure you have a deterministic sort order — always include the primary key as a tiebreaker. The response will include opaque cursor tokens in the links object (next, prev) instead of page numbers. Cursor pagination is ideal for infinite scroll and real-time feeds because it avoids the page-drift problem where new records shift items between pages.
Steven is a software engineer with a passion for building scalable web applications. He enjoys sharing his knowledge through articles and tutorials.