Lock in $30 Savings on PRO—Offer Ends Soon! ⏳

Symfony Mapper Component

Symfony Mapper Component

Mapping is something that you see in many frameworks over the web in every programming languages. Doctrine, for example, has a quite complex mapper to transform the relational database representation to your well-known entity.
After covering the history behind years of discussion and research by the Symfony community, we'll study the needs for such a component in Symfony. We'll analyse how different it is from the Symfony Serializer, and what are the solutions popular frameworks on the Web offer. At least, we'll showcase the new Mapper component and its application from API design to day to day use cases.

Antoine Bluchet

December 05, 2024
Tweet

Other Decks in Programming

Transcript

  1. Object mapping namespace App\Entity; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] class

    Book { #[ORM\Id, ORM\GeneratedValue] public ?int $id; #[ORM\Column] public ?string $title; } namespace App\ValueObject; readonly class Book { public function __construct( public string $id, public string $title ) {} }
  2. My fork of his patch is still available, this was

    working quite nicely! https://github.com/soyuka/symfony/commits/SERIALIZERAST/ 2017 2015 2016 2018 2019 2020 2021 2022 2023
  3. Joel Wurtz continues working on this and proposes another approach

    the AutoMapper https://github.com/symfony/symfony/pull/30248 2019 2015 2016 2018 2017 2020 2021 2022 2023
  4. AutoMapper is a simple little library built to solve a

    deceptively complex problem - getting rid of code that mapped one object to another. This type of code is rather dreary and boring to write, so why not invent a tool to do it for us? Automapper (.NET) https://automapper.org/
  5. PR was closed for housekeeping, didn’t got the votes to

    be merged. 2023 2015 2016 2018 2017 2020 2021 2022 2019
  6. Object mapping namespace App\Entity; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] class

    Book { #[ORM\Id, ORM\GeneratedValue] public ?int $id; #[ORM\Column] public ?string $title; } namespace App\ValueObject; readonly class Book { public function __construct( public string $id, public string $title ) {} }
  7. ➔ Handling legacy data representations ➔ Mapping from database model

    to DTO objects ➔ Data segregation ➔ Splitting, merging objects ➔ DDD When do we need object mapping?
  8. ➔ Spring https://github.com/FasterXML/jackson ➔ .NET https://automapper.org ➔ Java https://modelmapper.org ➔

    Ruby Object Mapper (ROM) https://rom-rb.org/ ➔ Java Map Struct https://mapstruct.org Object mapping in other programming languages:
  9. use App\ValueObject\Book; $data = $client->request('GET', 'https://example.com/books/1')->toArray(); try { $person =

    (new \CuyZ\Valinor\MapperBuilder()) ->mapper() ->map(Book::class, $data); } catch (\CuyZ\Valinor\Mapper\MappingError $error) { // Detailed error handling } Valinor takes care of the construction and validation of raw inputs (JSON, plain arrays, etc.) into objects, ensuring a perfectly valid state. It allows the objects to be used without having to worry about their integrity during the whole application lifecycle. https://valinor.cuyz.io/
  10. use AutoMapper\AutoMapper; use App\Entity\Book as BookEntity; use App\ValueObject\Book; $automapper =

    AutoMapper::create(); $source = new BookEntity(); $target = $automapper->map($source, Book::class); AutoMapper uses a convention-based matching algorithm to match up source to destination values. AutoMapper is geared towards model projection scenarios to flatten complex object models to DTOs and other simple objects, whose design is better suited for serialization, communication, messaging, or simply an anti-corruption layer between the domain and application layer. Automapper https://automapper.jolicode.com/
  11. use App\Entity\Book as BookEntity; use App\ValueObject\Book; #[AsMapper(from: BookEntity::class, to: Book::class)]

    class BookEntityToDtoMapper implements MapperInterface { public function load(object $from, string $toClass, array $context): object { $entity = $from; return new Book($entity->getId()); } public function populate(object $from, object $to, array $context): object { $to->id = (string) $from->getId(); $to->title = $from->getTitle(); return $to; } } MicroMapper: The Tiny, Underwhelming Data Mapper for Symfony! https://github.com/SymfonyCasts/micro-mapper
  12. More packages in PHP ➔ thephpleague/object-mapper https://github.com/thephpleague/object-mapper ➔ rekalogika/mapper https://github.com/rekalogika/mapper

    ➔ michelsalib/BCCAutoMapperBundle https://github.com/michelsalib/BCCAutoMapperBundle ➔ Nylle/PHP-AutoMapper https://github.com/Nylle/PHP-AutoMapper ➔ idr0id/Papper (inspired by .Net) https://github.com/idr0id/Papper
  13. Object hydration Hydration is filling an object with data Data

    Mapper pattern (often used by ORMs), hydrate an object from relational data (often represented using arrays). https://martinfowler.com/bliki/OrmHate.html
  14. ➔ The need exists ➔ A Symfony interface would allow

    more implementations/bundles to switch over to ➔ Automapper works well with Forms, API Platform, Messenger etc.) [RFC] ObjectMapper · Issue #54476 · symfony/symfony · GitHub Why another component ?
  15. The common vision of the Symfony community /** * @template

    T of object */ interface ObjectMapperInterface { /** * @param object $source The object to map from * @param T|class-string<T>|null $target The object or class to map to * * @return T */ public function map(object $source, object|string|null $target = null): object; } [RFC] ObjectMapper · Issue #54476 · symfony/symfony · GitHub
  16. Differences with the Serializer ➔ Separation of concerns ➔ Json

    Encoder component ➔ Compose with Symfony components (validation + mapping) ➔ Map only objects
  17. Map an object to another object ➔ No collection mapping

    yet (loop over your collection structure and transform objects) ➔ Mapping transformations and configuration (property renaming, concatenation, splitting, value transformation) ➔ Minimal interface to allow more implementations (MapStruct-like, Automapper for performances, etc.) Definition of the Symfony Object Mapper
  18. Metadata abstraction namespace Symfony\Component\ObjectMapper\Metadata; /** * Factory to create Mapper

    metadata. */ interface ObjectMapperMetadataFactoryInterface { /** * @param array<string, mixed> $context * * @return list<Mapping> */ public function create(object $object, ?string $property = null, array $context = []): array; }
  19. Metadata abstraction namespace Symfony\Component\ObjectMapper\Metadata; readonly class Mapping { /** *

    @param string|class-string|null $source The property or the class to map from * @param string|class-string|null $target The property or the class to map to * @param mixed $if A boolean, Symfony service name or a callable that instructs whether to map * @param mixed $transform A Symfony service name or a callable that transform the value during mapping */ public function __construct( public ?string $target = null, public ?string $source = null, public mixed $if = null, public mixed $transform = null, ) {} }
  20. Attribute-based implementation namespace Symfony\Component\ObjectMapper\Attribute; #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD |

    \Attribute::IS_REPEATABLE)] readonly class Map { /** * @param string|class-string|null $source The property or the class to map from * @param string|class-string|null $target The property or the class to map to * @param mixed $if A boolean, Symfony service name or a callable that instructs whether to map * @param mixed $transform A Symfony service name or a callable that transform the value during mapping */ public function __construct( public ?string $target = null, public ?string $source = null, public mixed $if = null, public mixed $transform = null, ) { } }
  21. Attribute-based implementation namespace App\Entity; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] class

    Book { #[ORM\Id, ORM\GeneratedValue] public ?int $id; #[ORM\Column] public ?string $title; }
  22. Attribute-based implementation namespace App\Entity; use App\ValueObject\Book as BookDTO; use Doctrine\ORM\Mapping

    as ORM; use Symfony\ObjectMapper\Attribute\Map; #[ORM\Entity, Map(target: BookDTO::class)] class Book { #[ORM\Id, ORM\GeneratedValue] public ?int $id; #[ORM\Column] public ?string $title; } use Symfony\Component\ObjectMapper\ObjectMapper; use App\ValueObject\Book as BookDTO; $book = $repository->find(1); // Retrieve a Book somehow; assert((new ObjectMapper)->map($book) instanceof BookDTO);
  23. Hydrate an existing instance namespace App\ValueObject; use App\Entity\BookEntity; readonly class

    Book { public function __construct( public string $id, public string $title ) {} }
  24. namespace App\ValueObject; use App\Entity\BookEntity; use Symfony\ObjectMapper\Attribute\Map; #[Map(target: BookEntity::class)] readonly class

    Book { public function __construct( #[Map(transform: 'intval')] public string $id, public string $title ) {} } use Symfony\Component\ObjectMapper\ObjectMapper; use App\ValueObject\Book as BookDTO; $dto = new BookDTO(id: '1', title: 'updated'); $book = $repository->find(1); // Retrieve a Book somehow; $book = (new ObjectMapper)->map($dto, $book); assert($book->title === 'updated'); Hydrate an existing instance
  25. | \Attribute::IS_REPEATABLE)] readonly class Map { /** * @param string|class-string|null

    $source The property or the class to map from * @param string|class-string|null $target The property or the class to map to * @param mixed $if A boolean, Symfony service name or a callable that instructs whether to map * @param mixed $transform A Symfony service name or a callable that transform the value during mapping */ public function __construct( public ?string $target = null, public ?string $source = null, public mixed $if = null, public mixed $transform = null, ) { } } Transformations
  26. namespace App\ValueObject; use App\Entity\BookEntity; use Symfony\ObjectMapper\Attribute\Map; #[Map(target: BookEntity::class)] readonly class

    Book { public function __construct( #[Map(transform: 'intval')] public string $id, #[Map(transform: static function ($title) { return ucfirst($title); })] public string $title ) {} } https://wiki.php.net/rfc/closures_in_const_expr Transform with a callable
  27. namespace App\Mapping; use Symfony\Component\ObjectMapper\TransformCallableInterface; use App\Entity\Book; /** * @implements TransformCallableInterface<Book>

    */ class TransformCallable implements TransformCallableInterface { public function __invoke(mixed $value, object $object): mixed { return ucfirst($value); } } Transform with a service
  28. namespace App\Entity; use Symfony\Component\ObjectMapper\Attribute\Map; use App\ValueObject\Book as BookDTO; use App\Mapping\TransformCallable;

    #[Map(BookDTO::class)] class Book { #[ORM\Column] #[Map(target: 'title', transform: TransformCallable::class)] public string $title; } Transform with a service
  29. \Attribute::IS_REPEATABLE)] readonly class Map { /** * @param string|class-string|null $source

    The property or the class to map from * @param string|class-string|null $target The property or the class to map to * @param mixed $if A boolean, Symfony service name or a callable that instructs whether to map * @param mixed $transform A Symfony service name or a callable that transform the value during mapping */ public function __construct( public ?string $target = null, public ?string $source = null, public mixed $if = null, public mixed $transform = null, ) { } } Conditionnal mapping
  30. use App\ValueObject\User as UserDTO; use Symfony\Component\ObjectMapper\Attribute\Map; #[Map(target: UserDTO::class)] class User

    { #[Map(if: false)] // This will never be mapped to UserDTO public ?string $password = null; } Conditional Mapping
  31. #[Map(target: User::class)] final class UserDTO { #[Map(target: 'firstName', transform: [self::class,

    'toFirstName'])] #[Map(target: 'lastName', transform: [self::class, 'toLastName'])] public string $username; public static function toFirstName(string $v): string { return explode(' ', $v)[0] ?? null; } public static function toLastName(string $v): string { return explode(' ', $v)[1] ?? null; } } Multiple targets on a property
  32. #[Map(target: PublishedBlogPost::class, if: static function ($value, $source) { return $source->status

    === 'published'; })] #[Map(target: DraftBlogPost::class, if: static function ($value, $source) { return $source->status === 'draft'; })] final class BlogPost { public ?string $status; } Multiple targets on a class https://wiki.php.net/rfc/closures_in_const_expr
  33. I see you… Can I define my mapping on: ➔

    the target instead of the source ? ➔ on a mapper class (like MicroMapper or MapStruct) ? ➔ Mapper builder (like valinor)
  34. Create your own ObjectMapperMetadataFactory! use Symfony\Component\ObjectMapper\Attribute\Map; use Symfony\Component\ObjectMapper\ObjectMapper; use Symfony\Component\ObjectMapper\ObjectMapperInterface;

    #[Map(source: Source::class, target: Target::class)] class AToBMapper implements ObjectMapperInterface { public function __construct(private readonly ObjectMapper $objectMapper) { } #[Map(source: 'propertyA', target: 'propertyD')] #[Map(source: 'propertyB', if: false)] public function map(object $source, object|string|null $target = null): object { return $this->objectMapper->map($source, $target); } } See the MapStructMapperMetadataFactory
  35. namespace App\ApiResource; #[ApiResource] readonly class Book { public function __construct(public

    string $id, public string $title) {} } 1. Define the API Resources with a PHP class
  36. namespace App\Entity; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] class Book {

    #[ORM\Column] public ?string $title; } namespace App\ApiResource; use App\Entity\Book as BookEntity; use ApiPlatform\Doctrine\Orm\State\Options; #[ApiResource(stateOptions: new Options(entityClass: BookEntity::class))] readonly class Book { public function __construct(public string $id, public string $title) {} } 2. Provide a data source (Doctrine Entity)
  37. PUT /books/1 Content-Type: "application/json" {"title": "1984"} 200 OK Content-Type: "application/json"

    {"title": "1984", "id": "1"} REQUEST App\Entity\Book { id: 1 title: 'Animal Farm' } RESPONSE
  38. PUT /books/1 Content-Type: "application/json" {"title": "1984"} 200 OK Content-Type: "application/json"

    {"title": "1984", "id": "1"} REQUEST RESPONSE App\ApiResource\Book { title: '1984' } App\Entity\Book { id: 1 title: 'Animal Farm' }
  39. https://symfony.com/doc/current/serializer.html use App\ApiResource\Book; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; // ... $book = new

    Book('1984'); $serializer->deserialize($jsonData, Book::class, 'json', [ AbstractNormalizer::OBJECT_TO_POPULATE => $book, ]); // instead of returning a new object, $book is updated instead The serializer can also be used to update an existing object. You can do this by configuring the object_to_populate serializer context option: Deserializing in an Existing Object
  40. PUT /books/1 Content-Type: "application/json" {"title": "1984"} 200 OK Content-Type: "application/json"

    {"title": "1984", "id": "1"} REQUEST RESPONSE App\ApiResource\Book { id: 1 title: 'Animal Farm' } ? App\ApiResource\Book { title: '1984' } App\Entity\Book { id: 1 title: 'Animal Farm' }
  41. namespace App\ApiResource; use App\Entity\Book as BookEntity; use ApiPlatform\Doctrine\Orm\State\Options; #[ApiResource(stateOptions: new

    Options(entityClass: BookEntity::class))] readonly class Book { public function __construct(public string $title) {} }
  42. namespace App\ApiResource; use App\Entity\Book as BookEntity; use ApiPlatform\Doctrine\Orm\State\Options; use Symfony\ObjectMapper\Attribute\Map;

    #[ApiResource(stateOptions: new Options(entityClass: BookEntity::class))] #[Map(target: BookEntity::class)] readonly class Book { public function __construct(public string $title) {} }
  43. PUT /books/1 Content-Type: "application/json" {"title": "1984"} 200 OK Content-Type: "application/json"

    {"title": "1984", "id": "1"} REQUEST RESPONSE App\ApiResource\Book { id: '1' title: 'Animal Farm' } $mapper->map($book); App\Entity\Book { id: 1 title: 'Animal Farm' }
  44. PUT /books/1 Content-Type: "application/json" {"title": "1984"} 200 OK Content-Type: "application/json"

    {"title": "1984", "id": "1"} REQUEST RESPONSE App\ApiResource\Book { title: '1984' } App\ApiResource\Book { id: '1' title: 'Animal Farm' }
  45. PUT /books/1 Content-Type: "application/json" {"title": "1984"} 200 OK Content-Type: "application/json"

    {"title": "1984", "id": "1"} REQUEST RESPONSE App\ApiResource\Book { id: '1' title: '1984' }
  46. PUT /books/1 Content-Type: "application/json" {"title": "1984"} 200 OK Content-Type: "application/json"

    {"title": "1984", "id": "1"} REQUEST RESPONSE App\Entity\Book { id: 1 title: 'Animal Farm' } $mapper->map($bookDto, $book); App\ApiResource\Book { id: '1' title: '1984' } App\Entity\Book { id: 1 title: '1984' }
  47. PUT /books/1 Content-Type: "application/json" {"title": "1984"} 200 OK Content-Type: "application/json"

    {"title": "1984", "id": "1"} REQUEST RESPONSE App\ApiResource\Book { id: '1' title: '1984' }
  48. PUT /books/1 Content-Type: "application/json" {"title": "1984"} 200 OK Content-Type: "application/json"

    {"title": "1984", "id": "1"} REQUEST RESPONSE App\ApiResource\Book { title: '1984' } App\ApiResource\Book { id: '1' title: '1984' } App\Entity\Book { id: 1 title: '1984' }
  49. /** * @template T of object */ interface ObjectMapperInterface {

    /** * @param object $source The object to map from * @param T|class-string<T>|null $target The object or class to map to * * @return T */ public function map(object $source, object|string|null $target = null): object; } [RFC] ObjectMapper · Issue #54476 · symfony/symfony · GitHub One interface to rule them all!
  50. Automapper ──────────────────────────────────────────── Language Files Lines Blanks Comments Code Complexity ────────────────────────────────────────────

    PHP 53 4597 751 1216 2630 230 ──────────────────────────────────────────── scc --include-ext php --exclude-dir Tests . Complexity
  51. Complexity Object Mapper ──────────────────────────────────────────── Language Files Lines Blanks Comments Code

    Complexity ──────────────────────────────────────────── PHP 12 615 86 235 294 41 ──────────────────────────────────────────── scc --include-ext php --exclude-dir Tests .
  52. symfony/object-mapper ➔ Object Mapper interface ➔ Low complexity implementation ➔

    Improves our work daily ➔ Is a good complement to other components (Validator, HttpKernel, Serializer) ➔ Dumped object mappers github.com/symfony/symfony/pull/51741
  53. Web and Cloud Experts ➔ Web development (PHP, JS, Go,

    Rust...) ➔ Support & hosting (FrankenPHP) apps ➔ Consultancy & maintenance ➔ UX & UI design ➔ [email protected] 💌
  54. Thanks! Sponsor me at github.com/soyuka Comment on the object mapper

    component: github.com/symfony/symfony/pull/51741 [email protected] @s0yuka soyuka.me soyuka.bsky.social