Be part of JetBrains PHPverse 2026 on June 9 – a free online event bringing PHP devs worldwide together.

gravity_global's avatar

JSON:API Resource collections is there an issue with docs?

So the docs have these as being equal ways to return a collection of JSON:API resources.

return PostResource::collection(Post::all());

return Post::all()->toResourceCollection();

However thats misleading.

The first would go Illuminate\Http\Resources\JsonApi\AnonymousResourceCollection which has the with method providing the 'included' in the output.

The section would attempt to look for a PostCollection class which extends Illuminate\Http\Resources\Json\ResourceCollection

That would with ?include= be adding on the 'relationships' but obviously won't add on the vital 'included' to get that data without subsequent calls.

From looking at the TransformsToResourceCollection trait it's clear you can pass a param of the resource class.

    public function toResourceCollection(?string $resourceClass = null): ResourceCollection
    {
        if ($resourceClass === null) {
            return $this->guessResourceCollection();
        }

        return $resourceClass::collection($this);
    }

Which would mean you should be passing PostResource as that param to achieve parity.

return PostResource::collection(Post::all());

return Post::all()->toResourceCollection(PostResource::class);

Any thoughts?

1 like
13 replies
LaryAI's avatar
Level 58

Certainly! Here’s a clear answer incorporating explanation and code examples as requested:


You're absolutely correct in your assessment: the Laracasts/Laravel documentation is a bit ambiguous and can mislead when it comes to using resource collections, especially with JSON:API formatting and how the included relationship data is handled.

What's Actually Happening?

1. This:

return PostResource::collection(Post::all());

Returns an anonymous resource collection (Illuminate\Http\Resources\Json\JsonResource::collection), which by default will handle JSON:API features such as the included key when dealing with relationships—assuming your PostResource is set up to provide it.

2. This:

return Post::all()->toResourceCollection();

Will (in the absence of a custom PostCollection) fallback to Illuminate\Http\Resources\Json\ResourceCollection, which doesn’t guarantee behavior parity with anonymous collections, particularly around JSON:API's included key and data structure unless further configured. This is important for those wanting a strict JSON:API compliant output, especially for handling relationships.

If you want to achieve identical behavior to the first usage, you do need to specify the resource class as a parameter:

3. Thus, you want:

return Post::all()->toResourceCollection(PostResource::class);

Which is equivalent to the first example, since it wraps each item in the specified resource and uses the correct logic for serialization.

Why Is This Important?

The difference can be subtle, but for a strict JSON:API implementation and third-party tooling (including Ember, Orbit, or frontend datastores), having the included data is essential for efficient resource linkage and to avoid extra network calls.

If you only use toResourceCollection() without $resourceClass, Laravel will try to look for PostCollection, which—unless you specifically configure—won't include JSON:API behaviors for "included" data.

In Summary

To ensure your API output behaves consistently and is fully JSON:API compliant (with relationships and included data handled as you expect):

Always explicitly specify the resource class when using toResourceCollection:

return PostResource::collection(Post::all());

// is equivalent to:

return Post::all()->toResourceCollection(PostResource::class);

Reference (from Laravel's /vendor/laravel/framework/src/Illuminate/Database/Eloquent/Collection.php):

public function toResourceCollection(?string $resourceClass = null): ResourceCollection
{
    if ($resourceClass === null) {
        return $this->guessResourceCollection();
    }

    return $resourceClass::collection($this);
}

Conclusion

So yes, the documentation can be misleading if you’re targeting strict JSON:API support. Always pass your resource class to toResourceCollection() (or use the static Resource::collection()), unless you've set up a custom collection that properly supports included (which is rare). This will guarantee consistent output.


Your observation is correct: pass the resource class as a parameter for parity, especially with JSON:API formatting!

1 like
imrandevbd's avatar

If you don't have a dedicated PostCollection class, it falls back to a basic ResourceCollection. That's where you lose the JSON:API specific behavior like the included key that PostResource::collection() handles automatically via AnonymousResourceCollection.

1 like
gravity_global's avatar

Hi @imrandevbd I don't think it does fallback at all.

From reading @laryai bots response again the 2. is incorrect in that

Will (in the absence of a custom PostCollection) fallback to Illuminate\Http\Resources\Json\ResourceCollection, which doesn’t guarantee behavior parity with anonymous collections, particularly around JSON:API's included key and data structure unless further configured.

If you see the toResourceCollection method it'll call guessResourceCollection()

    public function toResourceCollection(?string $resourceClass = null): ResourceCollection
    {
        if ($resourceClass === null) {
            return $this->guessResourceCollection();
        }

        return $resourceClass::collection($this);
    }

And that guessResourceCollection will throw a logic exception if in the example above a PostCollection class can't be found.

throw new LogicException(sprintf('Failed to find resource class for model [%s].', $className));

What it doesn't do is fallback to an anonymous or as described fallback to a generic ResourceCollection.

It's also important to clarify again the difference in the output if you use:

return Post::all()->toResourceCollection(); or return new PostCollection(Post::all());

It'll be this

[
    {
        "data": {
            "id": "1",
            "type": "posts",
            "attributes": {

            },
            "relationships": {
                "category": {
                    "data": {
                        "id": "2",
                        "type": "categories"
                    }
                }
            }
        }
    },

Whilst if you want the included to actually be part of the output which 99.9% will want if they are using the include param then you need to either pass the resource class explicitly

return Post::all()->toResourceCollection(PostResource::class);

or directly call the PostResource

return PostResource::collection(Post::all());

and note how you now get the expected object with data and included, not an array of objects.

{
    "data": [
        {
            "id": "1",
            "type": "posts",
            "attributes": {

            },
            "relationships": {
                "category": {
                    "data": {
                        "id": "2",
                        "type": "categories"
                    }
                }
            }
        },
],
"included" : [
        {
            "id": "2",
            "type": "categories",
            "attributes": {

1 like
gravity_global's avatar

I owe you an apologies, I think to clarify it will return ResourceCollection but only when its empty which should be never.

        if ($this->isEmpty()) {
            return new ResourceCollection($this);
        }

As far as that ending logic its getting to this where the $resourceClasses will be testing for a PostResource or Post inside that App\Http\Resources to auto create the collection.

        foreach ($resourceClasses as $resourceClass) {
            if (is_string($resourceClass) && class_exists($resourceClass)) {
                return $resourceClass::collection($this);
            }
        }

The exception comes from you having no PostResource or named Post inside that Resources.

        throw new LogicException(sprintf('Failed to find resource class for model [%s].', $className));

So the issue stems from if you have an existing Collection class in the resources folder, it'll use that old way and unless you bring your own with method to bring parity to how the individual responses work then its a mess.

1 like
gravity_global's avatar

So I think I need to make a request to update the docs.

But a second point is why isn't there simply a ResourceCollection class in the JsonAPI namespace?

https://github.com/laravel/framework/tree/13.x/src/Illuminate/Http/Resources/JsonApi

From doing a very quick test it would be the same with method thats in the AnonymousResourceCollection thats merging in that 'included'.

From what I can see the request would just need passing through to make JsonApiRequest as I think this would match the functionality

$jsonApiRequest = $this->resolveJsonApiRequestFrom($request);

If that was added then the Collection resources could just update the namespace to get parity with the individual, whilst being able to customise without jumping through hoops to get the included actually included.

use Illuminate\Http\Resources\Json\ResourceCollection;

to

use Illuminate\Http\Resources\JsonApi\ResourceCollection;
1 like
gravity_global's avatar

To clarify to @imrandevbd or anyone else trying to follow along the issue comes from having an existing Collection class and switching to using API JSON.

Because of this test before https://github.com/laravel/framework/blob/13.x/src/Illuminate/Collections/Traits/TransformsToResourceCollection.php#L70

        foreach ($resourceClasses as $resourceClass) {
            $resourceCollection = $resourceClass.'Collection';

            if (is_string($resourceCollection) && class_exists($resourceCollection)) {
                return new $resourceCollection($this);
            }
        }

It means that'll new up the Collection instead which then provides the relationships but won't include the 'included'.

This seems like a hidden gotcha to me.

So it then falls to either fudge the 'included' into your Collection, or make your own extension of the ResourceCollection to provide that to all your custom collection classes, or delete it to be able to get the same with that the anon class is providing.

Ideally the outcome is that there should be a ResourceCollection under the JsonApi namespace that provides the same 'included' output, and the namespace change for devs wanting to migrate to the JSON API standard.

1 like
vincent15000's avatar

I don't understand what you are trying to do.

I have used both codes and I get exactly the same output.

I always create both JsonResource and ResourceCollection, even if I don't customize the classes.

gravity_global's avatar

hi @vincent15000 the issue comes from making your own collection resource.

To recreate the issue make like the docs a PostResource for a Post model.

And then in the relationships add in a related model such as Category as in each Post has a category.

You can then call the post endpoint with the ?include=category so that it adds in the relationships and the included into the result when getting an individual post.

all good so far.

Next you want a index endpoint that'll get all posts so your method returns like the docs suggest and do

return Post::all()->toResourceCollection();

Excellent that'll then show all the posts and it'll have the relationships and the included to join that data together.

All works good so far.

Next you decide I want to make a PostCollection so you make that.

Then you test that index endpoint and you discover that it has the 'relationships' but the very important 'included' has disappeared!!

This is due to the anon class when using toResourceCollection having the with that adds that included but as soon as you make your own Collection like PostCollection you lose that with unless you jump through hoops to recreate what that anon class did.

1 like
vincent15000's avatar

What are you trying to do ?

Is it just to check how it works ?

Or do you need to customize the resource collection ?

Explain why you want to override toResourceCollection() ?

vincent15000's avatar

I have tested and it works fine.

Here is my code.

class Post extends Model
{
    use HasFactory;

    public function comments(): HasMany
    {
        return $this->hasMany(Comment::class);
    }
}
class PostResource extends JsonApiResource
{
    /**
     * The resource's attributes.
     */
    public $attributes = [
        'title',
        'comments',
    ];

    /**
     * The resource's relationships.
     */
    public $relationships = [
        'comments',
    ];
}
class CommentResource extends JsonApiResource
{
    /**
     * The resource's attributes.
     */
    public $attributes = [
        'content',
    ];

    /**
     * The resource's relationships.
     */
    public $relationships = [
        // ...
    ];
}
Route::get('api/posts', function () {
    return Post::all()->toResourceCollection();
});

And here is the result.

{"data":[{"id":"1","type":"posts","attributes":{"title":"asperiores deleniti deserunt"},"relationships":{"comments":{"data":[{"id":"1","type":"comments"},{"id":"2","type":"comments"},{"id":"3","type":"comments"}]}}},{"id":"2","type":"posts","attributes":{"title":"asperiores qui harum"},"relationships":{"comments":{"data":[{"id":"4","type":"comments"},{"id":"5","type":"comments"},{"id":"6","type":"comments"}]}}},{"id":"3","type":"posts","attributes":{"title":"mollitia officiis fugit"},"relationships":{"comments":{"data":[{"id":"7","type":"comments"},{"id":"8","type":"comments"},{"id":"9","type":"comments"}]}}},{"id":"4","type":"posts","attributes":{"title":"doloremque quae voluptatem"},"relationships":{"comments":{"data":[{"id":"10","type":"comments"},{"id":"11","type":"comments"},{"id":"12","type":"comments"}]}}},{"id":"5","type":"posts","attributes":{"title":"est incidunt ex"},"relationships":{"comments":{"data":[{"id":"13","type":"comments"},{"id":"14","type":"comments"},{"id":"15","type":"comments"}]}}}],"included":[{"id":"1","type":"comments","attributes":{"content":"Labore eos accusamus ipsa neque sit dolore officia illum. Dolorem aut laboriosam quibusdam consequatur."}},{"id":"2","type":"comments","attributes":{"content":"Quas quia nesciunt dolores quibusdam iste magni facilis libero. Qui in et dolor et corporis qui eius."}},{"id":"3","type":"comments","attributes":{"content":"Quis maxime accusamus molestiae dolor. Eum est est temporibus consectetur ut repudiandae."}},{"id":"4","type":"comments","attributes":{"content":"Libero quas aut et nisi. Commodi voluptas possimus facilis voluptatem modi molestiae perferendis."}},{"id":"5","type":"comments","attributes":{"content":"Doloribus quia similique totam quaerat. Maiores quibusdam necessitatibus est et vel ullam aut."}},{"id":"6","type":"comments","attributes":{"content":"Autem dolores ipsam aperiam sint dolorem et. Asperiores cumque tempore fugit voluptate mollitia id quia."}},{"id":"7","type":"comments","attributes":{"content":"Et velit voluptatem cupiditate deserunt voluptatum voluptatibus occaecati. Natus mollitia atque enim qui."}},{"id":"8","type":"comments","attributes":{"content":"Sint quo voluptatem quis et modi natus qui aspernatur. Est ea repudiandae vel est."}},{"id":"9","type":"comments","attributes":{"content":"Quibusdam omnis sed in excepturi omnis. Deserunt architecto aut labore corporis reiciendis."}},{"id":"10","type":"comments","attributes":{"content":"Asperiores voluptatem sapiente totam unde. Delectus est consequatur cum accusamus soluta doloremque odio."}},{"id":"11","type":"comments","attributes":{"content":"Vel nihil ea aspernatur libero amet velit cupiditate. Maxime ab possimus nulla accusantium."}},{"id":"12","type":"comments","attributes":{"content":"Qui excepturi veritatis eaque. Omnis explicabo repudiandae quod fugiat facere quia voluptatem."}},{"id":"13","type":"comments","attributes":{"content":"Blanditiis dolor et unde dolor. Consequatur iure harum eveniet voluptatum."}},{"id":"14","type":"comments","attributes":{"content":"Animi aperiam blanditiis ea quaerat amet dignissimos. Voluptas totam provident ut quod."}},{"id":"15","type":"comments","attributes":{"content":"Molestiae dolor ratione officiis nihil qui quasi quia. Qui a omnis voluptas est hic."}}]}

As you can see, the included key is present.

gravity_global's avatar

Yep now do

php artisan make:resource PostCollection --collection

And watch that included disappear from the output. So it'll have the relationships but no included to match up.

The reason being without your own Collection class it'll use

Illuminate\Http\Resources\JsonApi\AnonymousResourceCollection

has that with method which merges in the included.

However once you have a Collection it'll skip that AnonymousResourceCollection and use yours

which extends

Illuminate\Http\Resources\Json\ResourceCollection;

and what we really need is the missing ResourceCollection in the JsonApi namespace that has that equivalent with the AnonymousResourceCollection uses.

Illuminate\Http\Resources\JsonApi\ResourceCollection;

1 like
vincent15000's avatar

To use JSON:API Resources, you must not create a PostCollection.

As you can see, with a standard API resource, you can add meta datas and links in the resource collection.

https://laravel.com/docs/13.x/eloquent-resources#adding-meta-data

But for JSON:API resources, this is done directly in the API resource.

https://laravel.com/docs/13.x/eloquent-resources#jsonapi-links-and-meta

This let me think that you **must not create any ** API resource collection when using JSON:API Resources.

gravity_global's avatar

The documentation would be explicit if creating your own collection classes broke it. It would also have a section on those migrating to the new approach to remove them.

If it was true you must not then it doesn't make sense that in the toResourceCollection method it attempts to new up your PostCollection class before falling back to the Anonymous ResourceCollection.

$resourceCollection = $resourceClass.'Collection';

if (is_string($resourceCollection) && class_exists($resourceCollection)) {
    return new $resourceCollection($this);
}

so to clarify the mis-match in parity is that

UserResource::collection(User::all());

will create a new anonymous resource collection

    protected static function newCollection($resource)
    {
        return new AnonymousResourceCollection($resource, static::class);
    }

whilst

return User::all()->toResourceCollection();

will use the UserCollection first if it exists and then fallback to converting it into UserResource::collection and then throwing if that UserResource doesn't exist.

Please or to participate in this conversation.