Avant Symfony 7.3, transférer des données d'une entité Doctrine vers un DTO API Platform prenait trois chemins, tous imparfaits : un service de mapping écrit à la main — clair, mais vingt fois le même pattern pour vingt entités — mark-gerarts/automapper — puissant, mais une dépendance à justifier, une configuration YAML à maintenir, des classes générées en cache à surveiller — ou la sérialisation Symfony native contorsionnée avec des groupes de normalisation de plus en plus fragiles. Depuis le 29 mai 2025, Symfony 7.3 embarque une quatrième option : le composant ObjectMapper avec l'attribut #[Map], sans dépendance tierce, sans génération de code intermédiaire, sans configuration déclarative. Ce guide couvre les cas d'usage réels — entité Doctrine vers DTO API Platform, Command/Query CQRS en architecture hexagonale — et une comparaison honnête avec AutoMapper pour savoir quand chaque approche reste pertinente.
Pourquoi l'ObjectMapper, et pourquoi maintenant ?
Le problème est structurel dans toute architecture en couches sérieuse. Une entité Doctrine porte les contraintes de persistance, les relations lazy-loadées, les discriminants de table. Un DTO API Platform expose une vue aplatie, dénormalisée, adaptée à un contrat d'API précis. Les deux doivent rester étanches l'un à l'autre — c'est la règle de base de l'architecture hexagonale et du principe de ségrégation des interfaces.
Concrètement, sur un projet avec vingt entités, tu te retrouves avec vingt fois le même pattern : assignation propriété par propriété dans un DataTransformer ou un service dédié, parfois des méthodes toDto() qui fuient d'une couche à l'autre. La solution mark-gerarts/automapper a comblé ce vide depuis 2019 — elle fonctionne bien — mais elle génère des classes PHP intermédiaires en cache, demande une configuration non triviale, et introduit une dépendance externe que certaines équipes hésitent à accepter pour un problème aussi fondamental.
L'ObjectMapper prend un parti délibérément plus simple : tout se déclare via des attributs PHP 8 directement sur les classes source, aucun fichier généré, aucun cache propriétaire. La contrepartie est une expressivité moindre sur certains cas avancés — on y revient en détail dans la comparaison en fin d'article.
Installation : une commande, zéro configuration
composer require symfony/object-mapperC'est tout. Le composant est auto-configuré via Symfony Flex sur un projet 7.3+. Le service Symfony\Component\ObjectMapper\ObjectMapperInterface est disponible en autowiring immédiatement — aucun bundle à activer, aucune clé de configuration requise.
Point d'attention important : le composant est marqué @experimental dans Symfony 7.3. L'API publique peut évoluer avant la stabilisation prévue pour Symfony 7.4 (novembre 2025). Pour partir en production dès maintenant, épingle la version mineure dans ton composer.json et planifie une revue à la montée de version.
Entité Doctrine vers DTO API Platform : le cas fondateur
Prenons un cas concret et représentatif : une entité Product avec un champ priceInCents stocké en centimes, à exposer via API Platform sous la forme d'un prix formaté dans un DTO ProductOutput. La logique de formatage ne doit polluer ni l'entité ni le DTO — on l'encapsule dans un service transformer dédié.
<?php
// src/Entity/Product.php
namespace App\Entity;
use App\Dto\ProductOutput;
use App\ObjectMapper\PriceFormatter;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\ObjectMapper\Attribute\Map;
#[ORM\Entity]
#[Map(target: ProductOutput::class)]
class Product
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
public ?int $id = null;
#[ORM\Column(length: 255)]
public string $name = '';
#[ORM\Column]
public bool $active = false;
// priceInCents → displayPrice avec transformation à la volée
#[ORM\Column]
#[Map(target: 'displayPrice', transform: PriceFormatter::class)]
public int $priceInCents = 0;
}<?php
// src/ObjectMapper/PriceFormatter.php
namespace App\ObjectMapper;
use Symfony\Component\ObjectMapper\TransformCallableInterface;
final class PriceFormatter implements TransformCallableInterface
{
public function __invoke(mixed $value, object $source, ?object $target): string
{
// $value = $priceInCents, $source = l'entité Product complète
return number_format($value / 100, 2, ',', ' ') . '\u{00A0}€';
}
}<?php
// src/Dto/ProductOutput.php
namespace App\Dto;
class ProductOutput
{
public ?int $id = null;
public string $name = '';
public bool $active = false;
public string $displayPrice = ''; // alimenté par PriceFormatter
}<?php
// src/State/ProductProvider.php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Dto\ProductOutput;
use App\Repository\ProductRepository;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
final class ProductProvider implements ProviderInterface
{
public function __construct(
private readonly ProductRepository $repository,
private readonly ObjectMapperInterface $mapper,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ProductOutput
{
$product = $this->repository->find($uriVariables['id'])
?? throw new NotFoundHttpException();
/** @var ProductOutput $dto */
$dto = $this->mapper->map($product, ProductOutput::class);
return $dto;
}
}L'appel $this->mapper->map($product, ProductOutput::class) crée une instance de ProductOutput, copie les propriétés dont les noms correspondent directement (id, name, active), et applique PriceFormatter pour produire displayPrice depuis priceInCents. Aucune ligne de mapping manuel. Le transformer est un service Symfony à part entière : il peut recevoir des injections de dépendances (intl, taux de change, autre service métier).
Architecture hexagonale et CQRS : le cas où #[Map] brille
Besoin d'un expert Symfony ?
Réserver un appel →En architecture hexagonale, les Commands et Queries sont des Value Objects immuables qui traversent la frontière du domaine. Ils portent les données d'entrée brutes ; le Handler les traduit en entités de domaine. C'est exactement le schéma pour lequel #[Map] a été pensé : déclarer la correspondance au niveau du Command, laisser le Handler déléguer la construction de l'entité au mapper.
<?php
// src/Application/Command/RegisterUserCommand.php
namespace App\Application\Command;
use App\Domain\Entity\User;
use Symfony\Component\ObjectMapper\Attribute\Map;
#[Map(target: User::class)]
final readonly class RegisterUserCommand
{
public function __construct(
public string $email,
#[Map(target: 'username')]
public string $login, // login (Command) → username (Entity)
#[Map(if: false)]
public string $captchaToken, // jamais mappé vers l'entité
public string $locale = 'fr',
) {}
}<?php
// src/Application/Handler/RegisterUserHandler.php
namespace App\Application\Handler;
use App\Application\Command\RegisterUserCommand;
use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
#[AsMessageHandler]
final class RegisterUserHandler
{
public function __construct(
private readonly UserRepositoryInterface $repository,
private readonly ObjectMapperInterface $mapper,
) {}
public function __invoke(RegisterUserCommand $command): void
{
/** @var User $user */
$user = $this->mapper->map($command, User::class);
// $user->email, $user->username, $user->locale sont remplis
// $user->captchaToken n'existe pas → ignoré proprement
$this->repository->save($user);
}
}L'hexagone reste propre : le Command ne connaît pas l'entité, le Handler n'écrit aucune logique d'assignation. La règle de correspondance vit dans l'attribut, co-localisée avec la définition des données du Command — exactement là où un développeur qui lit ce fichier s'attend à trouver cette information. Le renommage login → username et l'exclusion de captchaToken sont déclaratifs, visibles sans ouvrir un second fichier.
L'attribut #[Map] de A à Z
L'attribut #[Map] s'applique à deux niveaux : au niveau classe (pour cibler une ou plusieurs classes de destination) et au niveau propriété (pour renommer, conditionner, transformer). Les deux niveaux sont combinables : une même propriété peut porter plusieurs #[Map] avec des cibles différentes.
<?php
// src/Entity/Order.php
namespace App\Entity;
use App\Dto\AdminOrderView;
use App\Dto\PublicOrderView;
use App\ObjectMapper\IsShippableCondition;
use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\ObjectMapper\Condition\TargetClass;
// Cible multiple : une entité, deux représentations API
#[Map(target: PublicOrderView::class)]
#[Map(target: AdminOrderView::class)]
class Order
{
public string $reference = '';
// Renommage simple
#[Map(target: 'createdOn')]
public \DateTimeImmutable $createdAt;
// Mappé seulement vers AdminOrderView
#[Map(target: 'buyerIp', if: new TargetClass(AdminOrderView::class))]
public ?string $lastIp = null;
// Mappé seulement si la condition métier est remplie
#[Map(if: IsShippableCondition::class)]
public ?string $shippingAddress = null;
// Ne jamais mapper ce champ, quelle que soit la cible
#[Map(if: false)]
public string $internalNote = '';
}target: ClassName::class(niveau classe) — spécifie la classe de destination ; plusieurs#[Map]autorisés pour des cibles multiplestarget: 'nomPropriété'(niveau propriété) — renomme la propriété dans la cibletransform: TransformerClass::class— service implémentantTransformCallableInterface; accepte aussi un callable statique[Classe::class, 'méthode']ou une fonction PHP native comme'strtolower'if: false— propriété toujours exclue du mapping, quelle que soit la cibleif: 'strlen'— callable PHP : la propriété est mappée seulement sistrlen($value)renvoie truthyif: ConditionClass::class— service implémentantConditionCallableInterfacepour des règles métier complexesif: new TargetClass(AdminView::class)— condition intégrée : mapping actif uniquement quand la cible est cette classe précise
Les services de transformation (TransformCallableInterface) et de condition (ConditionCallableInterface) partagent la même signature : __invoke(mixed $value, object $source, ?object $target). Cela donne accès à l'objet source complet depuis le transformer — pratique quand la valeur transformée dépend de plusieurs champs, par exemple construire un fullName depuis firstName en lisant $source->lastName.
ObjectMapper vs AutoMapper : comparaison honnête
mark-gerarts/automapper reste une bibliothèque excellente et mature. Choisir l'une ou l'autre n'est pas une question de qualité, mais de contexte. Voici les avantages réels de chaque approche.
- ObjectMapper — zéro dépendance externe : intégré à Symfony, pas de package tiers à auditer, à maintenir, à aligner sur les mises à jour de sécurité
- ObjectMapper — pas de génération de cache : aucun répertoire
var/cache/à invalider, aucune surprise en CI ou en déploiement blue/green - ObjectMapper — lisibilité co-localisée : les règles de mapping vivent dans la classe source, visibles sans ouvrir un fichier de configuration séparé
- ObjectMapper — readonly natif : fonctionne correctement avec les constructeurs
readonlyPHP 8.1+ et les classesfinal readonly - ObjectMapper — intégration profiler : les transformers sont des services Symfony ordinaires, tracés dans le Web Profiler et les logs de debug
- AutoMapper — stable et non-expérimental : API figée, pas de risque de rupture entre Symfony 7.3 et 7.4
- AutoMapper — collections Doctrine : mappe nativement les
ArrayCollectionet collections imbriquées vers des tableaux de DTOs sans transformer explicite ; ObjectMapper requiert un traitement manuel pour chaque collection - AutoMapper — mapping bidirectionnel : génère le mapping retour (DTO vers entité) sans configuration supplémentaire, précieux pour les formulaires Symfony et les opérations PATCH d'API
- AutoMapper — source non modifiable : mappe des classes tierces ou des entités héritées sur lesquelles tu n'as pas la main, sans toucher à leur code source —
#[Map]exige d'annoter la classe source - AutoMapper — mapping depuis tableau : convertit un
arrayassociatif en objet typé, utile pour désérialiser des payloads JSON de webhooks ou de réponses d'API tierces
En pratique, la frontière est nette. Sur un nouveau projet Symfony 7.3+ où toutes les entités t'appartiennent, préfère l'ObjectMapper : zéro dépendance, lisibilité maximale, intégration Symfony native. Dès que tu mappes des collections Doctrine imbriquées, des classes tierces, ou que tu as besoin d'un mapping bidirectionnel pour tes formulaires, AutoMapper reste le meilleur outil. Les deux coexistent sans friction dans un même projet — ObjectMapper pour les cas standards, AutoMapper là où sa puissance est justifiée.
Ce qu'il faut retenir
L'ObjectMapper n'est pas un remplacement d'AutoMapper : c'est une couverture native pour 80 % des cas. Il normalise le pattern de mapping le plus courant — entité vers DTO, Command vers entité — sans que chaque équipe réinvente la roue ou justifie une dépendance tierce. Le statut @experimental en 7.3 invite à la prudence sur l'API publique, pas sur l'approche : la direction est la bonne, et la stabilisation attendue en Symfony 7.4 devrait le confirmer. Si tu démarres un projet aujourd'hui, intègre l'ObjectMapper pour les cas standards, garde AutoMapper sous la main pour les scénarios avancés, et prévois une revue rapide au passage de version.
Besoin d'un expert Symfony ?
20 ans d'expérience sur l'écosystème PHP/Symfony.