DEV Community

Cover image for Laravel's Built-In JSON:API Resources vs timacdonald/json-api vs laravel-json-api: Which One Your API Needs
Hafiz
Hafiz

Posted on Originally published at hafiz.dev

Laravel's Built-In JSON:API Resources vs timacdonald/json-api vs laravel-json-api: Which One Your API Needs

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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());
});
Enter fullscreen mode Exit fullscreen mode
GET /api/builtin/eager/posts?include=author             2 queries
GET /api/builtin/eager/posts?include=author,comments    3 queries
Enter fullscreen mode Exit fullscreen mode

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',
    ];
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
{
    "data": {
        "id": "1",
        "type": "posts",
        "attributes": {
            "title": "Post 1"
        },
        "relationships": {
            "author": {
                "data": {
                    "id": "1",
                    "type": "users"
                }
            }
        }
    },
    "included": [
        {
            "id": "1",
            "type": "users",
            "attributes": {
                "name": "Hafiz"
            }
        }
    ]
}
Enter fullscreen mode Exit fullscreen mode

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."]}}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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];
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode
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');
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}]}
Enter fullscreen mode Exit fullscreen mode

?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
Enter fullscreen mode Exit fullscreen mode

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)