Slide 1

Slide 1 text

Symfony Mapper Component

Slide 2

Slide 2 text

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 ) {} }

Slide 3

Slide 3 text

2015 2016 2017 2018 2019 2020 2021 2022 2023

Slide 4

Slide 4 text

https://www.youtube.com/watch?v=taGGa2MAyG4 Kevin Dunglas presents API Platform 2015 2016 2017 2018 2019 2020 2021 2022 2023

Slide 5

Slide 5 text

https://dunglas.dev/2016/11/api-platform-2-0-released-creating-powerful-web-apis-has-never-been-so-easy/ 2016 2015 2017 2018 2019 2020 2021 2022 2023

Slide 6

Slide 6 text

https://github.com/api-platform/schema-generator/issues/42 2016 2015 2017 2018 2019 2020 2021 2022 2023

Slide 7

Slide 7 text

Joel Wurtz (jolicode) works on an AstGenerator component https://github.com/symfony/symfony/pull/17516 2016 2015 2017 2018 2019 2020 2021 2022 2023

Slide 8

Slide 8 text

https://github.com/api-platform/core/issues/212 2017 2015 2016 2018 2019 2020 2021 2022 2023

Slide 9

Slide 9 text

https://github.com/api-platform/core/issues/212 2017 2015 2016 2018 2019 2020 2021 2022 2023

Slide 10

Slide 10 text

GuilhemN takes the AST directly into the serializer’s normalizer https://github.com/symfony/symfony/pull/22051 2017 2015 2016 2018 2019 2020 2021 2022 2023

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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/

Slide 14

Slide 14 text

PR was closed for housekeeping, didn’t got the votes to be merged. 2023 2015 2016 2018 2017 2020 2021 2022 2019

Slide 15

Slide 15 text

My bad

Slide 16

Slide 16 text

2023 2015 2016 2018 2017 2020 2021 2022 2019

Slide 17

Slide 17 text

2023 2015 2016 2018 2017 2020 2021 2022 2019

Slide 18

Slide 18 text

https://symfonycasts.com/screencast/api-platform-extending/state-options state options + entityClass magic

Slide 19

Slide 19 text

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 ) {} }

Slide 20

Slide 20 text

➔ Handling legacy data representations ➔ Mapping from database model to DTO objects ➔ Data segregation ➔ Splitting, merging objects ➔ DDD When do we need object mapping?

Slide 21

Slide 21 text

➔ 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:

Slide 22

Slide 22 text

Do you think Symfony should provide an Object Mapper component ?

Slide 23

Slide 23 text

Mapping in PHP

Slide 24

Slide 24 text

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/

Slide 25

Slide 25 text

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/

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

Object mapping Transforming an object to another object

Slide 30

Slide 30 text

➔ 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 ?

Slide 31

Slide 31 text

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

Slide 32

Slide 32 text

Differences with the Serializer ➔ Separation of concerns ➔ Json Encoder component ➔ Compose with Symfony components (validation + mapping) ➔ Map only objects

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

Are you ready to see some code?

Slide 35

Slide 35 text

Metadata abstraction namespace Symfony\Component\ObjectMapper\Metadata; /** * Factory to create Mapper metadata. */ interface ObjectMapperMetadataFactoryInterface { /** * @param array $context * * @return list */ public function create(object $object, ?string $property = null, array $context = []): array; }

Slide 36

Slide 36 text

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, ) {} }

Slide 37

Slide 37 text

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, ) { } }

Slide 38

Slide 38 text

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; }

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

Hydrate an existing instance namespace App\ValueObject; use App\Entity\BookEntity; readonly class Book { public function __construct( public string $id, public string $title ) {} }

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

| \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

Slide 43

Slide 43 text

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

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

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

Slide 46

Slide 46 text

\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

Slide 47

Slide 47 text

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

Slide 48

Slide 48 text

#[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

Slide 49

Slide 49 text

#[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

Slide 50

Slide 50 text

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)

Slide 51

Slide 51 text

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

Slide 52

Slide 52 text

API Platform

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

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)

Slide 55

Slide 55 text

PUT /books/1 Content-Type: "application/json" {"title": "1984"} 200 OK Content-Type: "application/json" {"title": "1984", "id": "1"} REQUEST RESPONSE

Slide 56

Slide 56 text

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

Slide 57

Slide 57 text

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' }

Slide 58

Slide 58 text

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

Slide 59

Slide 59 text

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' }

Slide 60

Slide 60 text

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) {} }

Slide 61

Slide 61 text

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) {} }

Slide 62

Slide 62 text

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' }

Slide 63

Slide 63 text

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' }

Slide 64

Slide 64 text

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' }

Slide 65

Slide 65 text

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' }

Slide 66

Slide 66 text

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' }

Slide 67

Slide 67 text

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' }

Slide 68

Slide 68 text

https://github.com/api-platform/core/pull/6801 API Platform integration

Slide 69

Slide 69 text

What about performances? And @joel’s AutoMapper

Slide 70

Slide 70 text

/** * @template T of object */ interface ObjectMapperInterface { /** * @param object $source The object to map from * @param T|class-string|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!

Slide 71

Slide 71 text

[RFC] ObjectMapper · Issue #54476 · symfony/symfony · GitHub Dumping code is more efficient

Slide 72

Slide 72 text

[RFC] ObjectMapper · Issue #54476 · symfony/symfony · GitHub Reflection is slow?

Slide 73

Slide 73 text

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

Slide 74

Slide 74 text

Complexity Object Mapper ──────────────────────────────────────────── Language Files Lines Blanks Comments Code Complexity ──────────────────────────────────────────── PHP 12 615 86 235 294 41 ──────────────────────────────────────────── scc --include-ext php --exclude-dir Tests .

Slide 75

Slide 75 text

No content

Slide 76

Slide 76 text

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

Slide 77

Slide 77 text

Web and Cloud Experts ➔ Web development (PHP, JS, Go, Rust...) ➔ Support & hosting (FrankenPHP) apps ➔ Consultancy & maintenance ➔ UX & UI design ➔ [email protected] 💌

Slide 78

Slide 78 text

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