con un clic
object-mapper
Mappe un objet PHP vers un autre avec Symfony ObjectMapper (7.3+) —
Instalar con Codex o Claude Copia este prompt, pégalo en Codex, Claude u otro asistente, y deja que revise la página de la skill y la instale por ti.
Menú
Mappe un objet PHP vers un autre avec Symfony ObjectMapper (7.3+) —
Instalar con Codex o Claude Copia este prompt, pégalo en Codex, Claude u otro asistente, y deja que revise la página de la skill y la instale por ti.
Basado en la clasificación ocupacional SOC
Cadrage d'un article (sujet, thèse, audience, chapitrage, frontmatter). Détecte la stack (Astro, Hugo, Jekyll, MDX). Produit `docs/story/a-<NNN>-<slug>/plan.md`. Déclenche sur "idée d'article", "plan d'article", "j'écris sur…".
Retouche chirurgicale d'une portion d'article publié (chapitre, section, paragraphe). Lit le `plan.md` associé, respecte la voix, propage à la traduction. Déclenche sur "retravaille cette section", "réécris ce chapitre", "resserre ce paragraphe".
Rédige un article depuis le `plan.md` sous `docs/story/a-<NNN>-<slug>/` — fichier dans la collection détectée (Astro, Hugo, Jekyll, MDX), schéma + traduction. Déclenche sur "rédige depuis ce plan", "écris l'article", "draft l'article".
Crée une entité Sylius traduisible (pattern personal translations) : AbstractTranslation, TranslatableInterface, TranslatableTrait, locale fallback, ajout programmatique. Pour des libellés UI statiques → `/sylius:translation`.
Crée ou modifie une entité Doctrine (Symfony/Sylius) — ORM, champs, relations, types custom. Déclenche sur "créer entité", "relation ManyToOne", "UniqueEntity", "mapping Doctrine". Impose make:entity et snake_case BDD.
Conçoit une classe FormType Symfony — AbstractType, buildForm, configureOptions, types de champs (ChoiceType, EntityType…). Déclenche sur "créer FormType", "buildForm", "data_class", "EntityType". Impose make:form.
| name | object-mapper |
| description | Mappe un objet PHP vers un autre avec Symfony ObjectMapper (7.3+) — |
| user_invocable | true |
| allowed-tools | ["Read","Write","Edit","Glob","Grep","Bash(ls:*)","Bash(find:*)","Bash(cat:*)","Bash(git:*)","Bash(symfony:*)","Bash(bin/console:*)","Bash(composer:*)","Bash(vendor/bin/*:*)","Bash(./vendor/bin/*:*)"] |
Utilise quand tu transformes une instance PHP en une autre (DTO entrant → entité, entité → DTO de lecture, payload
stdClass→ objet typé) sans passer par un format texte intermédiaire (JSON, XML, CSV). Pas quand tu (dé)sérialises depuis/vers un format texte →/symfony:serializer-usecouvre tout le pipeline normalize/encode. Pas quand tu construis une couche API REST complète avec négociation de format → API Platform reste le bon outil. Pas quand tu fais une simple affectation manuelle de 2-3 propriétés —new Dto($entity->getId(), $entity->getName())reste plus lisible que d'invoquer le composant.
Tu déclares où vont les valeurs (attributs #[Map] côté source ou côté cible) et tu laisses ObjectMapperInterface::map() faire le walk : copie des propriétés homonymes, transformations, conditions, récursion détectée. Pas de format texte, pas de parsing — juste de la reflection + property access.
composer.json — le composant est symfony/object-mapper. Sinon composer require symfony/object-mapper./symfony:serializer-use avec un encodage JSON intermédiaire (overkill mais portable).symfony/property-access — dépendance transitive, mais une framework.property_access mal configurée peut faire planter les conditions sur propriétés absentes (cf. plus bas).api-platform/core — si présent, ne pas dupliquer un mapping qui sortirait déjà via une output resource Platform. Le mapper a sa place pour le code applicatif interne, pas pour réinventer la couche d'expo API.symfony/serializer est aussi mobilisé sur le même DTO : clarifier la séparation. Serializer = bord HTTP (JSON ↔ DTO entrant). ObjectMapper = bord domaine (DTO entrant → entité ; entité → DTO sortant). Mélanger les deux sur un même flux est rarement justifié.string JSON, c'est un job Serializer en amont.stdClass de la source via PropertyAccess, écrit dans la cible via PropertyAccess. Donc les invariants Doctrine (via setters), les hooks, les events de propriétés s'appliquent côté cible.map($source, Target::class) → instancie via new Target() (constructeur sans args appelé par défaut). map($source, $existingTarget) → mute l'instance fournie (utile pour PATCH partiel sur entité Doctrine).readonly + ctor), passer par une transformation de classe (#[Map(target: …, transform: [Target::class, 'createFrom'])]) qui fabrique l'objet et retourne l'instance prête.#[Map(target: …)], une propriété source name va vers la propriété cible name si elle existe. Renommer = ajouter #[Map(target: 'autreNom')]. Désactiver = #[Map(if: false)].#[Map] côté source ou côté cible — pas les deux sur la même paire de propriétés. Si c'est déclaré des deux côtés, la source prime (cf. doc). Choisir un côté : source quand le DTO entrant connaît sa cible ; cible quand l'entité métier veut rester ignorante du format d'entrée.ConditionCallableInterface::__invoke(mixed $value, object $source, ?object $target): bool. Une condition « PHP function » comme 'strlen' est un raccourci — l'argument c'est $value, donc strlen sur une string vide retourne 0 (falsy → exclu). Pratique mais piégeux.array → array ne mappe pas récursivement les éléments. Il faut #[Map(transform: new MapCollection())] sur la propriété pour que chaque item soit re-mappé selon ses propres règles.#[Map(target: …)] au niveau classe sans if explicite déclenchent une MappingException Ambiguous mapping. Une condition par cible, sinon une seule cible.// src/Dto/CreateProductInput.php
use Symfony\Component\ObjectMapper\Attribute\Map;
use App\Entity\Product;
#[Map(target: Product::class)]
final class CreateProductInput
{
public function __construct(
public readonly string $name,
#[Map(target: 'priceCents', transform: [self::class, 'eurosToCents'])]
public readonly float $priceEuros,
#[Map(if: false)] // ignoré au mapping
public readonly ?string $clientToken = null,
) {}
public static function eurosToCents(float $value): int
{
return (int) round($value * 100);
}
}
// src/Controller/ProductController.php
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
public function create(
CreateProductInput $input,
ObjectMapperInterface $mapper,
EntityManagerInterface $em,
): JsonResponse {
/** @var Product $product */
$product = $mapper->map($input); // target inféré depuis #[Map(target: …)]
$em->persist($product);
$em->flush();
return new JsonResponse(['id' => $product->getId()], 201);
}
Product doit avoir un constructeur sans args obligatoires (ou __construct() vide) pour que new Product() fonctionne. Sinon, pattern factory via transform au niveau classe (cf. plus bas).
Le DTO de sortie déclare ce qu'il prend depuis l'entité — l'entité reste ignorante :
// src/Dto/ProductView.php
use Symfony\Component\ObjectMapper\Attribute\Map;
use App\Entity\Product;
#[Map(source: Product::class)]
final class ProductView
{
public string $name = '';
#[Map(source: 'priceCents', transform: [self::class, 'centsToEuros'])]
public float $priceEuros = 0.0;
#[Map(source: 'createdAt')]
public ?\DateTimeImmutable $createdAt = null;
public static function centsToEuros(int $value): float
{
return $value / 100;
}
}
// Usage
$view = $mapper->map($product, ProductView::class);
Pattern recommandé : un ProductView par cas d'usage (ProductCardView, ProductDetailView, ProductAdminView) plutôt qu'un DTO unique avec quinze groupes.
#[Map] — paramètres| Paramètre | Type | Usage |
|---|---|---|
target | string|class-string | Au niveau classe : classe cible. Au niveau propriété : nom de la propriété cible. |
source | string|class-string | Au niveau classe : classe source attendue. Au niveau propriété : nom de la propriété source à lire. |
if | bool|callable|class-string | Condition d'inclusion. false désactive. Service ConditionCallableInterface pour logique riche. |
transform | callable|class-string | Transforme la valeur lue avant écriture. Service TransformCallableInterface pour logique avec accès à $source / $target. |
#[Map] est répétable au niveau classe (pour multi-cibles avec conditions) et au niveau propriété (pour cibler plusieurs propriétés différentes selon la classe cible).
#[Map(if: 'strlen')] // vrai si valeur non vide string
public ?string $discountCode = null;
#[Map(if: [Order::class, 'isShippable'])] // méthode statique
public ?string $shippingAddress = null;
ConditionCallableInterface)use Symfony\Component\ObjectMapper\ConditionCallableInterface;
final class IsAdultCondition implements ConditionCallableInterface
{
public function __invoke(mixed $value, object $source, ?object $target): bool
{
return $value instanceof \DateTimeInterface
&& $value->diff(new \DateTimeImmutable())->y >= 18;
}
}
#[Map(if: IsAdultCondition::class)]
public \DateTimeImmutable $birthDate;
Autoconfigure le tag (Symfony résout via le conditionCallableLocator). Pas besoin de déclarer le service manuellement si autowiring + autoconfigure sont actifs.
TargetClass)Quand la même classe source mappe vers plusieurs cibles, conditionner par classe cible :
use Symfony\Component\ObjectMapper\Condition\TargetClass;
#[Map(target: AdminUserView::class)]
#[Map(target: PublicUserView::class)]
final class User
{
public string $name = '';
#[Map(target: 'lastLoginIp', if: new TargetClass(AdminUserView::class))]
public ?string $lastLoginIp = null; // visible uniquement côté admin
}
Si une condition lit une propriété qui peut être manquante (cas legacy / payload partiel), désactiver l'exception stricte sur PropertyAccess :
# config/packages/framework.yaml
framework:
property_access:
throw_exception_on_invalid_property_path: false
Sinon une NoSuchPropertyException interrompt le mapping global.
#[Map(transform: 'intval')] // fonction PHP
#[Map(transform: [PriceFormatter::class, 'format'])] // méthode statique
#[Map(transform: fn (float $v): string => number_format($v, 2))] // closure
Signature acceptée : function (mixed $value, object $source = ?, ?object $target = ?): mixed. Les arguments suivants sont passés mais non requis — 'intval' (qui prend 1 arg) fonctionne, PHP ignore les surplus pour les built-ins.
TransformCallableInterfacePour une transformation qui dépend d'autres services ou de l'objet source complet :
use Symfony\Component\ObjectMapper\TransformCallableInterface;
final class FullNameTransformer implements TransformCallableInterface
{
public function __invoke(mixed $value, object $source, ?object $target): mixed
{
return trim($source->firstName.' '.$source->lastName);
}
}
#[Map(target: 'fullName', transform: FullNameTransformer::class)]
public string $firstName = ''; // $value = $source->firstName
Quand la cible n'a pas de constructeur sans args — typiquement DTO final readonly :
#[Map(target: Product::class, transform: [Product::class, 'createFromInput'])]
final class CreateProductInput
{
public function __construct(
public readonly string $name,
public readonly int $priceCents,
) {}
}
final class Product
{
private function __construct(
public readonly string $name,
public readonly int $priceCents,
public readonly \DateTimeImmutable $createdAt,
) {}
public static function createFromInput(mixed $value, object $source): self
{
return new self($source->name, $source->priceCents, new \DateTimeImmutable());
}
}
La transformation de classe remplace l'instanciation par défaut. Les #[Map] au niveau propriétés sont ensuite appliqués sur l'objet retourné — donc attention aux propriétés readonly : elles sont déjà figées par le constructeur, les #[Map] propriétés ne pourront pas les écraser (PropertyAccess lèvera).
MapCollectionSans MapCollection, un array source est copié tel quel — les éléments ne sont pas re-mappés.
use Symfony\Component\ObjectMapper\Transform\MapCollection;
final class OrderInput
{
/** @var ProductInput[] */
#[Map(transform: new MapCollection())]
public array $items = [];
}
Chaque ProductInput du tableau est mappé selon ses propres règles #[Map]. Le résultat reste un array côté cible (pas une Collection Doctrine — pour ça, transform custom qui wrappe).
La même classe source peut produire plusieurs cibles, conditionnées par l'état :
#[Map(target: OnlineEvent::class, if: [self::class, 'isOnline'])]
#[Map(target: PhysicalEvent::class, if: [self::class, 'isPhysical'])]
final class EventInput
{
public function __construct(
public readonly string $type,
public readonly string $title,
) {}
public static function isOnline(mixed $value, object $source): bool
{
return $source->type === 'online';
}
public static function isPhysical(mixed $value, object $source): bool
{
return $source->type === 'physical';
}
}
$event = $mapper->map(new EventInput('physical', 'PHP Forum')); // → PhysicalEvent
Conditions obligatoires sur chaque #[Map(target: …)] au niveau classe sinon Ambiguous mapping. Si on map($input, OnlineEvent::class) explicitement, les conditions sont court-circuitées — l'appelant a déjà tranché.
Le composant détecte les cycles et réutilise les instances déjà mappées. Aucun setup particulier requis :
#[Map(target: UserDto::class)]
final class User
{
public string $name = '';
public ?User $manager = null;
}
#[Map(source: User::class)]
final class UserDto
{
public string $name = '';
public ?UserDto $manager = null;
}
$bob = new User(); $bob->name = 'Bob';
$alice = new User(); $alice->name = 'Alice';
$bob->manager = $alice;
$alice->manager = $bob; // cycle
$bobDto = $mapper->map($bob, UserDto::class); // pas d'infinite loop
À comparer avec le Serializer qui exige un circular_reference_handler pour la même situation.
ObjectMapperInterfaceCas d'usage : logging, métriques, contexte multi-tenant, cache des graphes mappés.
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\ObjectMapper\ObjectMapperAwareInterface;
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
#[AsDecorator(decorates: ObjectMapperInterface::class)]
final class LoggingObjectMapper implements ObjectMapperInterface
{
private readonly ObjectMapperInterface $decorated;
public function __construct(
ObjectMapperInterface $decorated,
private readonly LoggerInterface $logger,
) {
$this->decorated = $decorated instanceof ObjectMapperAwareInterface
? $decorated->withObjectMapper($this)
: $decorated;
}
public function map(object $source, object|string|null $target = null): object
{
$this->logger->debug('Mapping', [
'source' => $source::class,
'target' => is_string($target) ? $target : ($target ? $target::class : 'inferred'),
]);
return $this->decorated->map($source, $target);
}
}
Une propriété readonly ne pouvant être assignée qu'une fois, on reçoit $decorated en argument non promu et on résout la version finale (avec ou sans withObjectMapper) dans une seule affectation.
Le withObjectMapper($this) est important : il garantit que les sous-mappings récursifs passent aussi par le décorateur, pas par le mapper original.
Quand on veut concentrer toutes les règles de mapping dans une classe mapper dédiée plutôt que sur les DTO/entités (séparation source/règles) :
use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\ObjectMapper\Metadata\MapStructMapperMetadataFactory;
use Symfony\Component\ObjectMapper\ObjectMapper;
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
#[Map(source: LegacyUser::class, target: UserDto::class)]
final class LegacyUserMapper implements ObjectMapperInterface
{
private readonly ObjectMapperInterface $objectMapper;
public function __construct(?ObjectMapperInterface $objectMapper = null)
{
$factory = new MapStructMapperMetadataFactory(self::class);
$this->objectMapper = $objectMapper ?? new ObjectMapper($factory);
}
#[Map(source: 'fullName', target: 'name')]
#[Map(source: 'emailAddr', target: 'email')]
public function map(object $source, object|string|null $target = null): object
{
return $this->objectMapper->map($source, $target);
}
}
Avantage : les classes métier restent vierges d'attributs #[Map]. Inconvénient : un mapper par paire source/cible, plus de plomberie. À réserver aux projets où DTO et entités sont dans des bounded contexts différents et ne doivent pas se connaître.
Pour appliquer un DTO entrant sur une entité Doctrine existante sans la réinstancier :
$product = $repository->find($id) ?? throw new NotFoundHttpException();
$mapper->map($input, $product); // mute l'instance fournie
$em->flush();
Toutes les propriétés du DTO sont écrites sur $product. Limite : pas de notion de « champ absent du payload » — un null côté DTO écrira null côté entité. Pour un vrai PATCH (seulement les champs présents), utiliser #[Map(if: …)] avec une condition sur valeur non-null ou dénormaliser via Serializer + OBJECT_TO_POPULATE (le Serializer respecte mieux la sémantique « champs présents uniquement »).
| Critère | ObjectMapper | Serializer |
|---|---|---|
| Source / cible | Objet PHP ↔ Objet PHP | Objet PHP ↔ texte (json/xml/csv/yaml) |
| Bord HTTP | Non | Oui |
| Performances | Reflection + PropertyAccess uniquement | Pipeline normalize + encode |
| API | map($source, $target) | serialize, deserialize, normalize, denormalize |
| Récursion | Auto (cycles détectés) | Manuel (circular_reference_handler) |
| Groupes | Non (utiliser conditions ou DTO dédiés) | #[Groups] |
| Mappings multiples cibles | #[Map] répétable + if | DTO distincts |
| Polymorphisme | TargetClass + conditions | #[DiscriminatorMap] |
| Composer | symfony/object-mapper | symfony/serializer |
Heuristique : pipeline HTTP entrant → Serializer dénormalise vers DTO (couche bord). DTO → entité interne → ObjectMapper. Entité → DTO sortant → ObjectMapper. DTO sortant → réponse HTTP → Serializer normalise + encode.
new Product() plante. Solutions : ajouter une transform de classe (factory statique), ou rendre les args du ctor optionnels.array non re-mappé : on attend que les enfants d'une collection soient mappés, ils sont copiés tels quels. Toujours #[Map(transform: new MapCollection())] sur les propriétés array typées.Ambiguous mapping : plusieurs #[Map(target: …)] au niveau classe sans if. Ajouter une condition par cible.'strlen' qui se comporte étrangement : strlen('') retourne 0 (falsy), donc une string vide est exclue. Pour inclure les strings vides, condition explicite.#[Map] source ET cible sur la même paire : la source prime, l'autre est ignorée silencieusement. Choisir un côté et y rester pour cette propriété.readonly côté cible avec #[Map] propriété : PropertyAccess lève à l'écriture. readonly exige une factory de classe (ctor unique).NoSuchPropertyException sur condition d'une propriété optionnelle : activer framework.property_access.throw_exception_on_invalid_property_path: false.map() un payload JSON brut (string). Le composant attend un objet (stdClass ou typé) — passer par json_decode($json) avant pour obtenir un stdClass.withObjectMapper($this) : les sous-mappings récursifs passent par le mapper original, le décorateur n'est pas traversé. Casse les métriques / le logging au-delà du premier niveau.map() direct sur entité managée : les listeners prePersist / preUpdate ne savent pas distinguer un mapping d'un changement métier. Si l'entité est managée et qu'on mute via map(), le flush() qui suit déclenche les hooks comme un update normal — comportement attendu, mais à garder en tête (ne pas mapper « pour voir »).map($src, $existing)) ?transform) ?TargetClass), champs ignorés (if: false), inclusion conditionnelle.if par cible).MapCollection sur chaque propriété array typée.composer require symfony/object-mapper si absent.#[Map(target|source: …)] au niveau classe ; #[Map] au niveau propriété pour renommer / transformer / conditionner.#[Map(target: …, transform: [Target::class, 'createFrom'])].ConditionCallableInterface.TransformCallableInterface.ObjectMapperInterface, appeler map($source, $target).composer show symfony/object-mapper # version installée
symfony console debug:autowiring ObjectMapperInterface
symfony console debug:container --tag=object_mapper.transform_callable
symfony console debug:container --tag=object_mapper.condition_callable
vendor/bin/phpstan analyse src
Test : un PHPUnit qui appelle $mapper->map(new SourceFixture(), Target::class) et asserte les valeurs cibles (préférer assertEquals sur des graphes d'objets, assertSame sur des scalaires).
Afficher :
#[Map] (source ou cible) et raison du choix.MapCollection ?ProductFactoryInterface, OrderFactoryInterface) pour construire ses resources avec leurs invariants (channels, locales, taxons par défaut). Ne pas remplacer une factory Sylius par un map() ObjectMapper — la factory porte de la logique métier (génération de slug, association de variantes par défaut, etc.) qu'un mapping perd.#[Map(source: Product::class)].Pipe de Platform peut suffire.ProductTranslation par locale) ne sont pas mappables naïvement : la propriété translations est une Collection, et il faut résoudre la locale courante. Soit transformation custom qui prend LocaleContextInterface, soit DTO qui n'expose qu'une chaîne name après résolution côté source.Product Sylius est rattaché à plusieurs channels ; le DTO sortant doit savoir lequel exposer. Injecter ChannelContextInterface dans un TransformCallableInterface plutôt que de propager le contexte channel à travers tout le code applicatif./symfony:object-mapper CreateProductInput — scaffolde un DTO entrant final avec #[Map(target: Product::class)], propriétés readonly, exemples de #[Map(target: …, transform: …)] sur les conversions usuelles. Lit Product existant pour caler les noms de propriétés.
/symfony:object-mapper "Product → ProductView" — scaffolde le DTO de lecture côté cible avec #[Map(source: Product::class)], transformations centisme→euros et formatage des dates.
/symfony:object-mapper FullNameTransformer — scaffolde un TransformCallableInterface avec signature complète, autoconfigure laisse Symfony tagger.
/symfony:object-mapper IsAdultCondition — scaffolde une ConditionCallableInterface.
/symfony:object-mapper "multi-target EventInput" — scaffolde une source avec deux #[Map(target: …, if: …)] au niveau classe et les méthodes statiques de discrimination.
/symfony:object-mapper src/Dto/CreateProductInput.php — audit d'un DTO existant : #[Map] cohérents (pas de mix source/cible ambigu), conditions sur les optionnels, MapCollection présent sur les array typés, factory si cible readonly, pas de propriété readonly ciblée par #[Map] propriété.
/symfony:object-mapper sans argument — demande le cas (DTO entrant, DTO sortant, transformer custom, condition custom, multi-cibles, audit, migration depuis un mapper manuel).