Exposer une collection paginée et filtrable avec DTO - Symfony
Ce cookbook décrit la mise en place côté backend Symfony + API Platform 4 d’une ressource paginée basée sur une entité Doctrine, tout en exposant un DTO qui ajoute un champ calculé fullName (firstName + lastName).
L’objectif : garder tout le confort d’API Platform (pagination, filtres, requêtes Doctrine) tout en maîtrisant le contrat d’API avec un DTO propre.
Le code complet (structure de répertoires, tests, configuration) est disponible dans le repo d’exemples Doing.
➡ Symfony : https://bitbucket.org/doingfr/doing-cookbooks-examples/src/main/collections/symfony/
Les extraits ci-dessous se concentrent sur les parties importantes pour comprendre le pattern (entité, DTO, provider, filtres, fixtures).
Comprendre le cas d'usage
Cas d'usage typique back-office :
- Côté base de données, on a une entité
Customeravec les “vrais” champs métier :firstName,lastName,age,city.
- Côté API, on ne veut pas exposer directement l’entité :
- on veut un
CustomerOutputavec un champ calculéfullName, - on veut maîtriser ce qui sort (versionning, compatibilité, sécurité).
- on veut un
- Côté frontend, on veut filtrer/ordonner sur
fullNamecomme si c’était un champ natif.
Utilise ce pattern dès que :
- ton modèle de base (entité Doctrine) ne colle pas à ton contrat d’API,
- tu dois calculer des champs (full name, âge, label combiné…),
- mais tu veux toujours profiter des filtres Doctrine + pagination API Platform.
Pré-requis
| Élément | Version / Remarque |
|---|---|
| PHP | 8.1+ |
| Symfony | 6.3+ / 7.x |
| API Platform | 4.2+ (nouveau système de QueryParameter) |
| Doctrine ORM | Configuré, base fonctionnelle |
| DoctrineFixtures | Installé (orm-fixtures) pour les données de test (optionnel) |
Schéma d’architecture
Déclarer la ressource principale : l’entité Customer
But de cette étape : configurer API Platform pour utiliser l’entité Doctrine comme ressource, tout en déclarant que les réponses seront des CustomerOutput et qu’on veut des paramètres de filtrage.
Si tu as déjà une entité User ou Customer dans ton projet, adapte simplement les champs et la ressource.
L’important ici est la déclaration des opérations (Get / GetCollection) et des parameters.
Structure minimale de l’entité
On part d’une entité Doctrine classique, sans se préoccuper encore des DTO ni des filtres :
<?php
namespace App\Entity\Customer;
use ApiPlatform\Metadata\ApiResource;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ApiResource]
class Customer
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 80)]
private string $firstName;
#[ORM\Column(length: 80)]
private string $lastName;
#[ORM\Column(type: 'integer')]
private int $age;
#[ORM\Column(length: 120)]
private string $city;
// Getters / setters classiques…
}
Ici, rien de spécial côté API Platform : l’entité est juste déclarée comme ressource.
Spécifier que la sortie est un DTO + provider custom
On veut maintenant :
- exposer un
CustomerOutput(et pas l’entité brute), - utiliser un provider custom qui fera le mapping entité → DTO,
- activer la pagination par défaut.
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\DTO\Customer\CustomerOutput;
use App\State\Customer\CustomerProvider;
#[ApiResource(
operations: [
new Get(
output: CustomerOutput::class,
provider: CustomerProvider::class,
),
new GetCollection(
output: CustomerOutput::class,
provider: CustomerProvider::class,
// on ajoutera les paramètres de filtre/tri juste après
),
],
paginationItemsPerPage: 20,
)]
class Customer
{
// … mêmes propriétés que précédemment
}
output: CustomerOutput::class: le contrat d’API est le DTO, pas l’entité.provider: CustomerProvider::class: c’est ce provider qui sera appelé pour récupérer les données.paginationItemsPerPage: 20: API Platform gère la pagination sans que le provider ait à s’en occuper.
Définir le DTO CustomerOutput
But : créer un type de sortie stable, qui ne dépend pas des détails de l’entité (firstName, lastName).
<?php
namespace App\Dto;
final class CustomerDto
{
public function __construct(
public int $id,
public string $fullName,
public int $age,
public string $city,
) {}
}
À ce stade :
fullNameest un champ calculé (on ne le stocke pas tel quel en base),- tu peux ajouter d’autres champs (labels, status lisibles…),
- tu peux retirer des champs sensibles présents sur l’entité.
L’API ne “voit” que ce DTO, ce qui facilite le versionning et la compatibilité.
Mapper entité avec le DTO grâce à un state provider
But : brancher proprement sur le provider Doctrine pour profiter des filtres / pagination, puis mapper Customer → CustomerDto.
Déclarer le provider et le brancher sur les providers Doctrine
<?php
namespace App\State\Customer;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\State\ProviderInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final class CustomerProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.collection_provider')]
private ProviderInterface $collectionProvider,
#[Autowire(service: 'api_platform.doctrine.orm.state.item_provider')]
private ProviderInterface $itemProvider,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
// … on gérera collection vs item juste après
}
}
Idée clé :
- tu réutilises les providers Doctrine fournis par API Platform,
- ton provider n’a qu’un rôle : convertir les
Customerrécupérés enCustomerOutput.
Gérer la collection : mapping + TraversablePaginator
use ApiPlatform\State\Pagination\TraversablePaginator;
use ApiPlatform\Doctrine\Orm\Paginator as ApiPlatformPaginator;
use ApiPlatform\Metadata\CollectionOperationInterface;
use App\Entity\Customer\Customer;
use App\DTO\Customer\CustomerOutput;
use ArrayIterator;
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
if ($operation instanceof CollectionOperationInterface) {
$result = $this->collectionProvider->provide($operation, $uriVariables, $context);
if ($result instanceof ApiPlatformPaginator) {
$dtos = [];
foreach ($result as $customer) {
\assert($customer instanceof Customer);
$dtos[] = $this->map($customer);
}
return new TraversablePaginator(
new ArrayIterator($dtos),
$result->getCurrentPage(),
$result->getItemsPerPage(),
$result->getTotalItems(),
);
}
// Cas sans pagination explicite
if (\is_iterable($result)) {
$dtos = [];
foreach ($result as $customer) {
\assert($customer instanceof Customer);
$dtos[] = $this->map($customer);
}
return $dtos;
}
return null;
}
// … gestion de l’item juste après
}
TraversablePaginator ?On renvoie un TraversablePaginator pour conserver tout ce qu’API Platform ajoute autour :
hydra:totalItems,hydra:viewpour la pagination,- les liens
next,previous, etc.
Doctrine fait la query et pagine, le provider se contente de remplacer les entités par des DTO.
Gérer l’item + fonction de mapping
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
// … gestion de la collection ci-dessus
// Item: /api/customers/{id}
$customer = $this->itemProvider->provide($operation, $uriVariables, $context);
if (!$customer instanceof Customer) {
return null;
}
return $this->map($customer);
}
private function map(Customer $customer): CustomerOutput
{
return new CustomerOutput(
id: $customer->getId(),
fullName: sprintf('%s %s', $customer->getFirstName(), $customer->getLastName()),
age: $customer->getAge(),
city: $customer->getCity(),
);
}
Ici, toute la logique de “comment on construit le DTO” est centralisée dans map(). Sur certains projets, nous utilisons une méthode hydrate dans le DTO.
Puisque le DTO n'est qu'un objet, la fonction de correspondance s'effectue dans le provider.
Comprendre et déclarer les Query Parameters
Dans API Platform 4.2+, la bonne approche est :
- déclarer un
QueryParametersur l’opération, - lui associer un filter qui implémente
FilterInterface, - ce filter manipule le
QueryBuilderDoctrine.
On va détailler les trois paramètres de ce cookbook :
fullName(filtre texte sur un champ calculé),age(filtre numérique avec cast),order[fullName](tri sur un champ calculé).
Déclaration des Query Parameters sur GetCollection
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\QueryParameter;
use App\Filter\FullNameFilter;
use App\Filter\AgeFilter;
use App\Filter\FullNameOrderFilter;
#[ApiResource(
operations: [
new GetCollection(
output: CustomerOutput::class,
provider: CustomerProvider::class,
parameters: [
'fullName' => new QueryParameter(
description: 'Filtre sur le nom complet (prénom + nom)',
filter: new FullNameFilter(),
),
'age' => new QueryParameter(
description: 'Filtre sur l’âge exact',
filter: new AgeFilter(),
schema: ['type' => 'integer'],
castToNativeType: true,
),
'order[fullName]' => new QueryParameter(
description: 'Tri sur le nom complet',
filter: new FullNameOrderFilter(),
schema: ['type' => 'string', 'enum' => ['asc', 'desc']],
),
],
),
],
)]
class Customer
{
// …
}
Clés importantes :
'fullName'→ query string :/api/customers?fullName=dupont.'age'→/api/customers?age=30.'order[fullName]'→/api/customers?order[fullName]=asc.
schema et castToNativeTypeschema: ['type' => 'integer']documente le paramètre comme entier dans OpenAPI.castToNativeType: truedemande à API Platform de convertir la valeur string ("30") enintavant de la passer au filtre.
Petit récap :
| Paramètre | Ex dans l’URL | Rôle côté backend |
|---|---|---|
fullName | /api/customers?fullName=dupont | Filtre sur CONCAT(firstName, ' ', lastName) (LIKE) |
age | /api/customers?age=30 | Filtre sur le champ age égal à un entier |
order[fullName] | /api/customers?order[fullName]=asc | Tri par nom complet (ASC/DESC) |
Implémenter les filtres Doctrine
Filtre texte fullName (LIKE sur champ calculé)
Objectif : permettre ?fullName=dupont qui filtre sur CONCAT(firstName, ' ', lastName).
Signature et récupération de la valeur
<?php
namespace App\Filter;
use ApiPlatform\Doctrine\Orm\Filter\FilterInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
final class FullNameFilter implements FilterInterface
{
public function apply(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void {
$parameter = $context['parameter'] ?? null;
if (!$parameter) {
return;
}
$value = $parameter->getValue();
if ($value === null || $value === '') {
return;
}
// … on ajoutera la condition juste après
}
}
À retenir :
FilterInterface::apply()est appelée uniquement si le paramètrefullNameest présent dans l’URL.context['parameter']représente ce query param ;getValue()renvoie la valeur déjà normalisée.- On sort tôt si le paramètre est absent ou vide pour ne pas polluer la requête.
Construire l’expression SQL et appliquer la condition
$alias = $queryBuilder->getRootAliases()[0];
$paramName = $queryNameGenerator->generateParameterName('fullName');
$expr = $queryBuilder->expr()->like(
"LOWER(CONCAT($alias.firstName, ' ', $alias.lastName))",
':' . $paramName
);
$queryBuilder
->andWhere($expr)
->setParameter($paramName, '%' . mb_strtolower((string) $value) . '%');
À retenir :
$alias: alias principal de l’entité dans la requête (souvento,c, etc.).generateParameterName('fullName'): évite les collisions de paramètres quand plusieurs filtres s’appliquent.- L’expression cible le “full name” construit en SQL :
CONCAT(firstName, ' ', lastName)puisLOWER(...)pour une recherche insensible à la casse,LIKE :paramavec%value%pour une recherche “contient”.
Filtre numérique age (égalité + cast automatique)
Objectif : permettre ?age=30 avec cast automatique en int.
Récupérer un entier grâce à castToNativeType
<?php
namespace App\Filter;
use ApiPlatform\Doctrine\Orm\Filter\FilterInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
final class AgeFilter implements FilterInterface
{
public function apply(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void {
$parameter = $context['parameter'] ?? null;
if (!$parameter) {
return;
}
$value = $parameter->getValue();
if (!\is_int($value)) {
// castToNativeType=true dans QueryParameter => déjà casté en int normalement
return;
}
// … on applique la condition juste après
}
}
Grâce à castToNativeType: true dans la déclaration du QueryParameter, getValue() renvoie directement un int lorsqu’on appelle /api/customers?age=30.
Appliquer la condition d’égalité sur le champ age
$alias = $queryBuilder->getRootAliases()[0];
$paramName = $queryNameGenerator->generateParameterName('age');
$queryBuilder
->andWhere("$alias.age = :$paramName")
->setParameter($paramName, $value);
On reste volontairement simple : un seul champ, une égalité stricte. Tu peux étendre plus tard (intervalle, min/max…) en ajoutant d’autres paramètres.
Filtre de tri order[fullName] (ORDER BY sur champ calculé)
Objectif : permettre ?order[fullName]=asc|desc pour trier par nom complet.
Valider la direction du tri
<?php
namespace App\Filter;
use ApiPlatform\Doctrine\Orm\Filter\FilterInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
final class FullNameOrderFilter implements FilterInterface
{
public function apply(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void {
$parameter = $context['parameter'] ?? null;
if (!$parameter) {
return;
}
$rawDirection = $parameter->getValue();
if ($rawDirection === null || $rawDirection === '') {
return;
}
$direction = strtoupper((string) $rawDirection);
if (!\in_array($direction, ['ASC', 'DESC'], true)) {
return;
}
// … on ajoute réellement le ORDER BY juste après
}
}
On ne fait rien si la valeur n’est pas ASC ou DESC → le tri reste inchangé.
Ajouter le ORDER BY sur le champ calculé
$alias = $queryBuilder->getRootAliases()[0];
// ORDER BY LOWER(CONCAT(firstName, ' ', lastName)) ASC|DESC
$expr = "LOWER(CONCAT($alias.firstName, ' ', $alias.lastName))";
$queryBuilder->addOrderBy($expr, $direction);
Si tu préfères un tri plus classique :
$queryBuilder
->addOrderBy("$alias.lastName", $direction)
->addOrderBy("$alias.firstName", $direction);
Tu gardes l’API order[fullName] côté client, mais le tri réel est “Nom puis Prénom”.
Ajouter des fixtures pour tester rapidement
But : avoir des données suffisamment variées pour tester les filtres et le tri.
<?php
namespace App\DataFixtures;
use App\Entity\Customer;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
class CustomerFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
$firstNames = ['Jean', 'Sarah', 'Paul', 'Emma', 'Lucas', 'Clara'];
$lastNames = ['Dupont', 'Martin', 'Lopez', 'Bernard', 'Morel', 'Renaud'];
$cities = ['Lyon', 'Paris', 'Marseille', 'Bordeaux', 'Lille'];
for ($i = 0; $i < 30; $i++) {
$customer = new Customer();
$customer->setFirstName($firstNames[array_rand($firstNames)]);
$customer->setLastName($lastNames[array_rand($lastNames)]);
$customer->setAge(random_int(18, 70));
$customer->setCity($cities[array_rand($cities)]);
$manager->persist($customer);
}
// Cas de test “canonique”
$specific = new Customer();
$specific
->setFirstName('Jean')
->setLastName('Dupont')
->setAge(30)
->setCity('Paris');
$manager->persist($specific);
$manager->flush();
}
}
Chargement :
php bin/console doctrine:fixtures:load
Tester
- curl
# Liste paginée
curl "https://api.example.test/api/customers"
# Filtre sur le nom complet
curl "https://api.example.test/api/customers?fullName=dupont"
# Filtre combiné
curl "https://api.example.test/api/customers?fullName=jean&age=30"
# Tri ASC sur fullName
curl "https://api.example.test/api/customers?order[fullName]=asc"
# Filtre + tri
curl "https://api.example.test/api/customers?fullName=dupont&order[fullName]=asc"
Checklist de mise en prod
| Point | OK ? |
|---|---|
| DTO bien découplé de l’entité | ✅ |
| Provider testé (item + collection) | ✅ |
Filtres fullName, age testés sur un volume réaliste | ✅ |
Tri order[fullName] test sur plusieurs pages | ✅ |
| Pagination par défaut raisonnable (20/50) | ✅ |
| Documentation OpenAPI claire (descriptions des QueryParameter) | ✅ |
FAQ
Pourquoi ne pas utiliser #[ApiFilter] ?
À partir d’API Platform 4.2, la config par annotation ApiFilter est dépréciée et sera supprimée en v5.
Le pattern recommandé est d’utiliser QueryParameter + des filtres qui implémentent FilterInterface, ce que démontre ce cookbook.
Pourquoi garder l’entité comme ressource principale, et pas le DTO ?
Parce que toute la puissance d’API Platform (filtres Doctrine, pagination, extensions, etc.) repose sur une ressource Doctrine. On garde donc l’entité comme “source de vérité” et on se contente de mapper la sortie vers un DTO via un state provider.
Comment étendre à d’autres champs du DTO ?
Tu peux :
- ajouter un nouveau champ au DTO,
- soit le brancher directement sur un champ Doctrine (simple),
- soit écrire un filtre custom qui traduit le paramètre en condition SQL (comme pour
fullName), - et/ou ajouter un
QueryParameterpour le tri (ORDER BY) similaire au filtreFullNameOrderFilter.