Description
API Platform version(s) affected: 4.1.x
Description
When using DTOs with collections in API Platform, there is inconsistent behavior between REST and GraphQL operations.
In REST operations, collections (both Doctrine Collections and arrays) within DTOs are automatically serialized correctly as arrays in the response.
However, in GraphQL operations, the same collections are automatically wrapped in a cursor-based pagination structure (edges/nodes) but the content is returned as null
despite the collections containing data:
{
"data": {
"book": {
"title": "Sample Book",
"author": {
"name": "John Doe"
},
"categories": {
"edges": null
}
}
}
}
Disabling pagination at the resource level fixes this issue by removing the cursor structure, but this should work out of the box like it does for REST operations, or at least be clearly documented how to handle this case.
How to reproduce
- Create a simple DTO structure:
<?php
namespace App\Dto;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\GraphQl\Query;
use Doctrine\Common\Collections\Collection;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
shortName: 'Book',
normalizationContext: ['groups' => ['book:read']],
graphQlOperations: [
new Query(resolver: BookResolver::class),
]
)]
class Book
{
#[Groups(['book:read'])]
public string $title;
#[Groups(['book:read'])]
public Author $author;
#[Groups(['book:read'])]
/** @var Collection<int, Category> */
public Collection $categories;
}
<?php
namespace App\Dto;
use ApiPlatform\Metadata\ApiResource;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(operations: [], graphQlOperations: [])]
class Author
{
#[Groups(['book:read'])]
public string $name;
}
<?php
namespace App\Dto;
use ApiPlatform\Metadata\ApiResource;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(operations: [], graphQlOperations: [])]
class Category
{
#[Groups(['book:read'])]
public string $name;
}
- Create a resolver for GraphQL:
<?php
namespace App\GraphQl\Resolver;
use App\Dto\Author;
use App\Dto\Book;
use App\Dto\Category;
use Doctrine\Common\Collections\ArrayCollection;
use Psr\Log\LoggerInterface;
class BookResolver
{
public function __construct(
private LoggerInterface $logger
) {
}
public function __invoke(): Book
{
$book = new Book();
$book->title = "Sample Book";
$author = new Author();
$author->name = "John Doe";
$book->author = $author;
// Create a collection with categories
$categories = new ArrayCollection();
$category1 = new Category();
$category1->name = "Fiction";
$categories->add($category1);
$category2 = new Category();
$category2->name = "Adventure";
$categories->add($category2);
$book->categories = $categories;
// Log to demonstrate the collection has content
$this->logger->info('Categories count: ' . $categories->count());
return $book;
}
}
- Run a GraphQL query:
query {
book {
title
author {
name
}
categories {
edges {
node {
name
}
}
}
}
}
The result will show the single properties correctly, but the categories collection will show edges: null
despite having items in it.
Possible Solution
There are currently one workaround:
- Disable pagination at the resource level:
#[ApiResource(
paginationEnabled: false,
// ...
)]
But this workaround shouldn't be necessary. The system should either:
- Automatically handle collections in DTOs for GraphQL operations just like it does for REST
- Properly document that collections in DTOs require special configuration for GraphQL
- Ensure that when pagination is enabled, the collection content is still properly normalized (not returned as null)
Additional Context
In the logs, we can verify that the collection has items, but they're not appearing in the GraphQL response unless pagination is disabled.
The issue seems to be in how API Platform handles the normalization process for collections in GraphQL vs REST operations. REST operations correctly handle collections in DTOs automatically, while GraphQL operations don't unless pagination is explicitly disabled.
When pagination is enabled (the default), the collection is wrapped in a cursor-based pagination structure, but the content is returned as null, despite the collection having items.