Originally published at hafiz.dev
On Laravel 13.38.0, a request for 20 posts with ?include=author,comments ran 41 queries through the framework's own JSON:API resources. The fix is merged into the 13.x branch. It just isn't in a release yet.
I found that while building the same small API three ways: with Laravel's built-in JsonApiResource, with timacdonald/json-api, and with laravel-json-api/laravel. Same three models (users, posts, comments), same 20 posts and 100 comments, on a clean Laravel 13.38.0 project with SQLite. Each request went through a small middleware that counts queries. All output below is real.
Two of the three look almost the same from the outside. The third is a different kind of tool. One question tells you which you need. (If you're still deciding how the API itself should behave, my production API practices post is the place to start.)
Check your version first
If you already use the built-in resources, three things depend on your Laravel version.
GHSA-5wcm-3v2w-8362. A moderate advisory, published on 19 October. A crafted request to a read-only endpoint could trigger a destructive operation on a related table through relationship inclusion. It affects 12.45.0 up to 12.68.0, and every 13.x release before 13.27.0. Fixed in 12.68.0 and 13.27.0.
Nested includes from 13.27 to 13.36. Release 13.27.0 also changed how resources load relationships (#61322). From then on, nested includes ran one query per related model until #62000 fixed it in 13.37.0. I installed each version and ran the same request, a user with 20 posts and 5 comments per post:
GET /api/builtin/users/1?include=posts.comments.author
13.26.0 4 queries
13.36.0 122 queries
13.38.0 4 queries
Collection includes, on every release so far. #62000 fixed single resources. Collections still load each item's includes one model at a time. This one predates the regression. Issue #61252 reported 101 queries for 100 records on 13.24. The fix is #62152, merged on 22 October, two days after 13.38.0 shipped:
13.26.0 13.36.0 13.38.0 13.x-dev
GET /posts?include=author 21 21 21 2
GET /posts?include=author,comments 41 41 41 3
Until the next tag, eager-load whatever the client asked for. The resource skips relations that are already loaded:
Route::get('/posts', function (Request $request) {
$includes = array_intersect(
explode(',', $request->query('include', '')),
['author', 'comments', 'comments.author'],
);
return PostResource::collection(Post::with($includes)->get());
});
GET /api/builtin/eager/posts?include=author 2 queries
GET /api/builtin/eager/posts?include=author,comments 3 queries
I diffed the response bodies against the version without with(), and they were identical. Keep the allow-list. Eloquent resolves each name passed to with() by calling it as a method on a fresh model instance, so a raw include string lets the client choose which method runs. (If you want the wider picture on spotting N+1 queries, my query optimization post walks through it.)
The built-in JsonApiResource
It arrived in Laravel 12.45.0 (#57571) and is documented in the API resources docs. php artisan make:resource PostResource --json-api generates a class with two properties, and filling them in is most of the work:
namespace App\Http\Resources;
use Illuminate\Http\Resources\JsonApi\JsonApiResource;
class PostResource extends JsonApiResource
{
public $attributes = [
'title',
'body',
];
public $relationships = [
'author' => UserResource::class,
'comments',
];
}
Both relationships resolve on their own. The built-in finds CommentResource and UserResource from the related models, so the explicit class on author is optional. I kept it to show the syntax. A route returns it like any resource, and sparse fieldsets and includes work from the query string:
GET /api/builtin/posts/1?include=author&fields[posts]=title&fields[users]=name
{
"data": {
"id": "1",
"type": "posts",
"attributes": {
"title": "Post 1"
},
"relationships": {
"author": {
"data": {
"id": "1",
"type": "users"
}
}
}
},
"included": [
{
"id": "1",
"type": "users",
"attributes": {
"name": "Hafiz"
}
}
]
}
The response goes out as application/vnd.api+json. A paginator gives you first, last, prev and next links, with Laravel's usual pagination meta and a plain ?page=2 parameter. Nesting stops at five levels by default, and JsonApiResource::maxRelationshipDepth() changes that. toLinks() and toMeta() add per-resource links and meta.
That's the whole job. The built-in shapes responses. It doesn't read JSON:API requests or enforce the rest of the spec, and three gaps showed up straight away.
It ignores include paths it doesn't know. ?include=secrets returned 200 with the post and no error. The JSON:API spec says a server that can't identify a relationship path "MUST respond with 400 Bad Request".
Errors come out in Laravel's own format, not as JSON:API error objects. Here's a 422 from a POST route I wrote by hand:
{"message":"The data.attributes.title field is required. (and 1 more error)","errors":{"data.attributes.title":["The data.attributes.title field is required."],"data.attributes.body":["The data.attributes.body field is required."]}}
And writes are yours to build. That route validates data.type and data.attributes.* with ordinary rules, then returns the new resource with a 201:
Route::post('/posts', function (Request $request) {
$validated = $request->validate([
'data.type' => ['required', 'in:posts'],
'data.attributes.title' => ['required', 'string', 'max:120'],
'data.attributes.body' => ['required', 'string'],
]);
$post = $request->user()->posts()->create($validated['data']['attributes']);
return (new PostResource($post))->response()->setStatusCode(201);
});
It works. But relationships in the request body, error objects with JSON pointers, and content negotiation are all on you. For filters and sorting, the docs point you at Spatie's Laravel Query Builder, which supports Laravel 12 and 13 in its current 7.x release (it needs PHP 8.4).
timacdonald/json-api
Tim MacDonald's package has been around since its first release in November 2021, and the built-in uses the same property-based API:
use TiMacDonald\JsonApi\JsonApiResource;
class PostResource extends JsonApiResource
{
public $attributes = ['title', 'body'];
public $relationships = ['author' => UserResource::class, 'comments' => CommentResource::class];
}
I compared the package and the built-in on three requests (a single post with includes, the collection, and a user with sparse fieldsets). Once the keys were sorted, the documents were identical. Then I changed only the use line in my three package resources to the built-in Illuminate\Http\Resources\JsonApi\JsonApiResource, and the same three requests still returned identical documents.
The other direction needs care. The package guesses the resource class from the relationship key, not from the related model, so 'author' with no class failed with a 500, because there's no AuthorResource. Moving from the package to the built-in was the use line. Moving from the built-in to the package can mean adding explicit classes.
The difference is loading. The package doesn't eager-load by design, and its README says so plainly. It recommends Spatie's query builder for that job. Without it:
GET /api/tim/users/1?include=posts.comments.author 123 queries
GET /api/tim/posts?include=author 22 queries
GET /api/tim/posts?include=author,comments 43 queries
With the same with() allow-list from earlier, those collection requests dropped to 2 and 3 queries.
It's still a pre-release. The latest tag is 1.0.0-beta.12, from March 2026. The repository isn't archived, and its constraints cover Laravel 11, 12 and 13. That makes it the right choice on Laravel 11, or on a 12.x app older than 12.45.0. On anything newer I wouldn't start a new API on it, because the built-in gives you the same API from the framework itself, and the N+1 fix lands in the framework too. If you're already on it, my migration for these three resources was the use line.
laravel-json-api/laravel
This is a different kind of tool. It isn't a resource class, it's a full JSON:API server. Version 5.3.0 (July 2026) supports Laravel 12.61.1 and up and 13.x.
Setup is more work. I published the config, generated a server with php artisan jsonapi:server v1, registered it in config/jsonapi.php, generated three schemas and a request class, registered routes, and added its exception renderer in bootstrap/app.php. A schema describes fields instead of output:
class PostSchema extends Schema
{
public static string $model = Post::class;
protected int $maxDepth = 3;
public function fields(): array
{
return [
ID::make(),
Str::make('title')->sortable(),
Str::make('body'),
BelongsTo::make('author')->type('users'),
HasMany::make('comments'),
DateTime::make('createdAt')->sortable()->readOnly(),
DateTime::make('updatedAt')->sortable()->readOnly(),
];
}
public function filters(): array
{
return [
WhereIdIn::make($this),
Where::make('author', 'user_id'),
];
}
public function pagination(): ?Paginator
{
return PagePagination::make();
}
}
JsonApiRoute::server('v1')->prefix('v1')->resources(function ($server) {
$server->resource('posts', JsonApiController::class)
->relationships(function ($relations) {
$relations->hasOne('author')->readOnly();
$relations->hasMany('comments')->readOnly();
});
$server->resource('users', JsonApiController::class)->only('index', 'show');
});
That route block registered create, update and delete for posts, plus the related-resource and relationship endpoints (/posts/{post}/author, /posts/{post}/relationships/comments), with no controller code of mine.
Then it does the things the built-in leaves to you. Includes are eager-loaded with no help:
GET /api/v1/users/1?include=posts.comments.author 4 queries
GET /api/v1/posts?include=author 2 queries
GET /api/v1/posts?include=author,comments 3 queries
Include paths are validated. The default $maxDepth is 1, which is why the schema above raises it. Before I did, the nested request came back like this, and so does any path the schema doesn't declare:
{"jsonapi":{"version":"1.0"},"errors":[{"detail":"Include path posts.comments.author is not allowed.","source":{"parameter":"include"},"status":"400","title":"Invalid Query Parameter"}]}
?filter[author]=2, ?sort=-title and ?page[number]=2&page[size]=5 worked straight away. Writes are validated against the request class, and the errors point at the exact member of the request document:
422 Unprocessable Entity | The title field is required. | /data/attributes/title
422 Unprocessable Entity | The body field is required. | /data/attributes/body
422 Unprocessable Entity | The author field is required. | /data/relationships/author
A POST with a plain application/json body was refused with a 415. A proper document, with the author passed as a relationship, created the post and returned it with a 201.
Two costs to know about. Authorization runs through your policies by default. I switched it off for this test with authorizable() returning false on the server, but in a real app you'd write the policies (my policies vs gates guide covers those). And it brings its own conventions, like camelCase createdAt, its own controller, and a schema layer your team has to learn.
Which one your API needs
Built-in JsonApiResource
|
timacdonald/json-api | laravel-json-api/laravel | |
|---|---|---|---|
| What it is | Resource classes | Resource classes | Full server |
| Minimum Laravel | 12.45.0 | 11 | 12.61.1 |
| Eager-loads includes | Single resources (13.37+), collections not yet | No, by design | Yes |
| Unknown include path | Ignored, 200 | Ignored, 200 | 400 error object |
| Filters, sorting | Spatie Query Builder | Spatie Query Builder | Built in |
| Writes and validation | Your code | Your code | Request classes, JSON pointers |
| Error format | Laravel's | Laravel's | JSON:API error objects |
| Status | First-party | 1.0.0-beta.12 | 5.3.0 |
The question that decides it is whether clients write through the API.
If they only read, and what you need is the JSON:API shape for a front end or a mobile app, use the built-in. You get includes, sparse fieldsets and links from three small classes, and the remaining N+1 is a framework bug with a fix already merged. Add the with() allow-list until it ships.
If clients create and update resources through it, or a third party will build against your API and expects the spec enforced (error objects, rejected include paths, filters and pagination by the book), use laravel-json-api. Its setup cost is real, but you'd spend more than that rebuilding its validation and errors by hand on top of the built-in.
And timacdonald/json-api is for apps that can't run the built-in yet.
FAQ
Does Laravel's built-in JSON:API resource support filtering and sorting?
No. It shapes responses and handles include and fields. The Laravel docs point to Spatie's Laravel Query Builder for filters and sorts, and its current 7.x release supports Laravel 12 and 13 on PHP 8.4.
Can the built-in JsonApiResource accept JSON:API documents in POST or PATCH?
Only through your own code. You validate data.attributes.* with normal rules and create the model yourself. Validation errors come back in Laravel's default format, not as JSON:API error objects.
Is timacdonald/json-api abandoned?
No. The repository isn't archived and the latest release, 1.0.0-beta.12, supports Laravel 11 to 13. It's still a pre-release, though, and on Laravel 12.45.0 or newer the built-in offers the same property-based API.
Why does laravel-json-api reject my include with a 400?
Either the path isn't a relationship on the schema, or it's deeper than the schema's $maxDepth, which defaults to 1. The JSON:API spec requires a 400 when a server can't support an include path, and the package follows it.
Which Laravel version fixes the JSON:API N+1 queries?
Nested includes on a single resource are fixed in 13.37.0. Includes on collections are fixed on the 13.x branch by #62152, which was merged after 13.38.0 and should ship in the next 13.x release. Check the release notes for #62152 before you remove a with() workaround.
Conclusion
Whichever you pick, run composer show laravel/framework before you ship it. Anything from 13.27.0 to 13.36.0 loads nested includes one model at a time, and every release up to 13.38.0 does the same for collection includes until #62152 is tagged.
Top comments (0)