Aller au contenu principal

Exposer une collection paginée et filtrable avec DTO - Symfony

remarque

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.

Code source complet

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).

Symfony : 1h30

Comprendre le cas d'usage

Cas d'usage typique back-office :

  • Côté base de données, on a une entité Customer avec les “vrais” champs métier :
    • firstName, lastName, age, city.
  • Côté API, on ne veut pas exposer directement l’entité :
    • on veut un CustomerOutput avec un champ calculé fullName,
    • on veut maîtriser ce qui sort (versionning, compatibilité, sécurité).
  • Côté frontend, on veut filtrer/ordonner sur fullName comme si c’était un champ natif.
Quand utiliser ce pattern ?

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émentVersion / Remarque
PHP8.1+
Symfony6.3+ / 7.x
API Platform4.2+ (nouveau système de QueryParameter)
Doctrine ORMConfiguré, base fonctionnelle
DoctrineFixturesInstallé (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.

remarque

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 :

src/Entity/Customer/Customer.php
<?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
}
Ce que fait vraiment cette config
  • 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).

src/DTO/Customer/CustomerOutput.php
<?php

namespace App\Dto;

final class CustomerDto
{
public function __construct(
public int $id,
public string $fullName,
public int $age,
public string $city,
) {}
}

À ce stade :

  • fullName est 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 CustomerCustomerDto.

Déclarer le provider et le brancher sur les providers Doctrine

src/State/Customer/CustomerProvider.php
<?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 Customer récupérés en CustomerOutput.

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
}
Pourquoi TraversablePaginator ?

On renvoie un TraversablePaginator pour conserver tout ce qu’API Platform ajoute autour :

  • hydra:totalItems,
  • hydra:view pour 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 QueryParameter sur l’opération,
  • lui associer un filter qui implémente FilterInterface,
  • ce filter manipule le QueryBuilder Doctrine.

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

src/Entity/Customer/Customer.php
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.
Rôle de schema et castToNativeType
  • schema: ['type' => 'integer'] documente le paramètre comme entier dans OpenAPI.
  • castToNativeType: true demande à API Platform de convertir la valeur string ("30") en int avant de la passer au filtre.

Petit récap :

ParamètreEx dans l’URLRôle côté backend
fullName/api/customers?fullName=dupontFiltre sur CONCAT(firstName, ' ', lastName) (LIKE)
age/api/customers?age=30Filtre sur le champ age égal à un entier
order[fullName]/api/customers?order[fullName]=ascTri 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

src/Filter/FullNameFilter.php
<?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ètre fullName est 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 (souvent o, 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) puis LOWER(...) pour une recherche insensible à la casse,
    • LIKE :param avec %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

src/Filter/AgeFilter.php
<?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

src/Filter/FullNameOrderFilter.php
<?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);
Variante “annuaire”

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.

src/DataFixtures/CustomerFixtures.php
<?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

# 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

PointOK ?
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 QueryParameter pour le tri (ORDER BY) similaire au filtre FullNameOrderFilter.