Skip to content

Object Mapper

Edit this page

7.3

The ObjectMapper component was introduced in Symfony 7.3 as an experimental feature.

This component transforms one object into another, simplifying tasks such as converting DTOs (Data Transfer Objects) into entities or vice versa. It can also be helpful when decoupling API input/output from internal models, particularly when working with legacy code or implementing hexagonal architectures.

Installation

Run this command to install the component before using it:

1
$ composer require symfony/object-mapper

Usage

The object mapper service will be autowired automatically in controllers or services when type-hinting for ObjectMapperInterface:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// src/Controller/UserController.php
namespace App\Controller;

use App\Dto\UserInput;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\ObjectMapper\ObjectMapperInterface;

class UserController extends AbstractController
{
    public function updateUser(UserInput $userInput, ObjectMapperInterface $objectMapper): Response
    {
        $user = new User();
        // Map properties from UserInput to User
        $objectMapper->map($userInput, $user);

        // ... persist $user and return response
        return new Response('User updated!');
    }
}

Basic Mapping

The core functionality is provided by the map() method. It accepts a source object and maps its properties to a target. The target can either be a class name (to create a new instance) or an existing object (to update it).

Mapping to a New Object

Provide the target class name as the second argument:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use App\Dto\ProductInput;
use App\Entity\Product;
use Symfony\Component\ObjectMapper\ObjectMapper;

$productInput = new ProductInput();
$productInput->name = 'Wireless Mouse';
$productInput->sku = 'WM-1024';

$mapper = new ObjectMapper();
// creates a new Product instance and maps properties from $productInput
$product = $mapper->map($productInput, Product::class);

// $product is now an instance of Product
// with $product->name = 'Wireless Mouse' and $product->sku = 'WM-1024'

Mapping to an Existing Object

Provide an existing object instance as the second argument to update it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use App\Dto\ProductUpdateInput;
use App\Entity\Product;
use Symfony\Component\ObjectMapper\ObjectMapper;

$product = $productRepository->find(1);

$updateInput = new ProductUpdateInput();
$updateInput->price = 99.99;

$mapper = new ObjectMapper();
// updates the existing $product instance
$mapper->map($updateInput, $product);

// $product->price is now 99.99

Mapping from stdClass

The source object can also be an instance of stdClass. This can be useful when working with decoded JSON data or loosely typed input:

1
2
3
4
5
6
7
8
9
10
11
use App\Entity\Product;
use Symfony\Component\ObjectMapper\ObjectMapper;

$productData = new \stdClass();
$productData->name = 'Keyboard';
$productData->sku = 'KB-001';

$mapper = new ObjectMapper();
$product = $mapper->map($productData, Product::class);

// $product is an instance of Product with properties mapped from $productData

Configuring Mapping with Attributes

ObjectMapper uses PHP attributes to configure how properties are mapped. The primary attribute is Map.

Defining the Default Target Class

Apply #[Map] to the source class to define its default mapping target:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/Dto/ProductInput.php
namespace App\Dto;

use App\Entity\Product;
use Symfony\Component\ObjectMapper\Attribute\Map;

#[Map(target: Product::class)]
class ProductInput
{
    public string $name = '';
    public string $sku = '';
}

// now you can call map() without the second argument if ProductInput is the source:
$mapper = new ObjectMapper();
$product = $mapper->map($productInput); // Maps to Product automatically

Configuring Property Mapping

You can apply the #[Map] attribute to properties to customize their mapping behavior:

  • target: Specifies the name of the property in the target object;
  • source: Specifies the name of the property in the source object (useful
    when mapping is defined on the target, see below);
  • if: Defines a condition for mapping the property;
  • transform: Applies a transformation to the value before mapping.

This is how it looks in practice:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// src/Dto/OrderInput.php
namespace App\Dto;

use App\Entity\Order;
use Symfony\Component\ObjectMapper\Attribute\Map;

#[Map(target: Order::class)]
class OrderInput
{
    // map 'customerEmail' from source to 'email' in target
    #[Map(target: 'email')]
    public string $customerEmail = '';

    // do not map this property at all
    #[Map(if: false)]
    public string $internalNotes = '';

    // only map 'discountCode' if it's a non-empty string
    // (uses PHP's strlen() function as a condition)
    #[Map(if: 'strlen')]
    public ?string $discountCode = null;
}

By default, if a property exists in the source but not in the target, it is ignored. If a property exists in both and no #[Map] is defined, the mapper assumes a direct mapping when names match.

Conditional Mapping with Services

For complex conditions, you can use a dedicated service implementing ConditionCallableInterface:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// src/ObjectMapper/IsShippableCondition.php
namespace App\ObjectMapper;

use App\Dto\OrderInput;
use App\Entity\Order; // Target type hint
use Symfony\Component\ObjectMapper\ConditionCallableInterface;

/**
 * @implements ConditionCallableInterface<OrderInput, Order>
 */
final class IsShippableCondition implements ConditionCallableInterface
{
    public function __invoke(mixed $value, object $source, ?object $target): bool
    {
        // example: Only map shipping address if order total is above 50
        return $source->total > 50;
    }
}

Then, pass the service name (its class name by default) to the if parameter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/Dto/OrderInput.php
namespace App\Dto;

use App\Entity\Order;
use App\ObjectMapper\IsShippableCondition;
use Symfony\Component\ObjectMapper\Attribute\Map;

#[Map(target: Order::class)]
class OrderInput
{
    public float $total = 0.0;

    #[Map(if: IsShippableCondition::class)]
    public ?string $shippingAddress = null;
}

For this to work, IsShippableCondition must be registered as a service.

Conditional Property Mapping based on Target

When a source class maps to multiple targets, you may want to include or exclude certain properties depending on which target is being used. Use the TargetClass condition within the if parameter of a property's #[Map] attribute to achieve this.

This pattern is useful for building multiple representations (e.g., public vs. admin) from a given source object, and can be used as an alternative to serialization groups:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// src/Entity/User.php
namespace App\Entity;

use App\Dto\AdminUserProfile;
use App\Dto\PublicUserProfile;
use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\ObjectMapper\Condition\TargetClass;

// this User entity can be mapped to two different DTOs
#[Map(target: PublicUserProfile::class)]
#[Map(target: AdminUserProfile::class)]
class User
{
    // map 'lastLoginIp' to 'ipAddress' ONLY when the target is AdminUserProfile
    #[Map(target: 'ipAddress', if: new TargetClass(AdminUserProfile::class))]
    public ?string $lastLoginIp = '192.168.1.100';

    // map 'registrationDate' to 'memberSince' for both targets
    #[Map(target: 'memberSince')]
    public \DateTimeImmutable $registrationDate;

    public function __construct() {
        $this->registrationDate = new \DateTimeImmutable();
    }
}

// src/Dto/PublicUserProfile.php
namespace App\Dto;
class PublicUserProfile
{
    public \DateTimeImmutable $memberSince;
    // no $ipAddress property here
}

// src/Dto/AdminUserProfile.php
namespace App\Dto;
class AdminUserProfile
{
    public \DateTimeImmutable $memberSince;
    public ?string $ipAddress = null; // mapped from lastLoginIp
}

// usage:
$user = new User();
$mapper = new ObjectMapper();

$publicProfile = $mapper->map($user, PublicUserProfile::class);
// no IP address available

$adminProfile = $mapper->map($user, AdminUserProfile::class);
// $adminProfile->ipAddress = '192.168.1.100'

Transforming Values

Use the transform option within #[Map] to change a value before it is assigned to the target. This can be a callable (e.g., a built-in PHP function, static method, or anonymous function) or a service implementing TransformCallableInterface.

Using Callables

Consider the following static utility method:

1
2
3
4
5
6
7
8
9
10
// src/Util/PriceFormatter.php
namespace App\Util;

class PriceFormatter
{
    public static function format(float $value, object $source): string
    {
        return number_format($value, 2, '.', '');
    }
}

You can use that method to format a property when mapping it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// src/Dto/ProductInput.php
namespace App\Dto;

use App\Entity\Product;
use App\Util\PriceFormatter;
use Symfony\Component\ObjectMapper\Attribute\Map;

#[Map(target: Product::class)]
class ProductInput
{
    // use a static method from another class for formatting
    #[Map(target: 'displayPrice', transform: [PriceFormatter::class, 'format'])]
    public float $price = 0.0;

    // can also use built-in PHP functions
    #[Map(transform: 'intval')]
    public string $stockLevel = '100';
}

Using Transformer Services

Similar to conditions, complex transformations can be encapsulated in services implementing TransformCallableInterface:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/ObjectMapper/FullNameTransformer.php
namespace App\ObjectMapper;

use App\Dto\UserInput;
use App\Entity\User;
use Symfony\Component\ObjectMapper\TransformCallableInterface;

/**
 * @implements TransformCallableInterface<UserInput, User>
 */
final class FullNameTransformer implements TransformCallableInterface
{
    public function __invoke(mixed $value, object $source, ?object $target): mixed
    {
        return trim($source->firstName . ' ' . $source->lastName);
    }
}

Then, use this service to format the mapped property:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/Dto/UserInput.php
namespace App\Dto;

use App\Entity\User;
use App\ObjectMapper\FullNameTransformer;
use Symfony\Component\ObjectMapper\Attribute\Map;

#[Map(target: User::class)]
class UserInput
{
    // this property's value will be generated by the transformer
    #[Map(target: 'fullName', transform: FullNameTransformer::class)]
    public string $firstName = '';

    public string $lastName = '';
}

Class-Level Transformation

You can define a transformation at the class level using the transform parameter on the #[Map] attribute. This callable runs after the target object is created (if the target is a class name, newInstanceWithoutConstructor is used), but before any properties are mapped. It must return a correctly initialized instance of the target class (replacing the one created by the mapper if needed):

1
2
3
4
5
6
7
8
9
10
11
12
13
// src/Dto/LegacyUserData.php
namespace App\Dto;

use App\Entity\User;
use Symfony\Component\ObjectMapper\Attribute\Map;

// use a static factory method on the target User class for instantiation
#[Map(target: User::class, transform: [User::class, 'createFromLegacy'])]
class LegacyUserData
{
    public int $userId = 0;
    public string $name = '';
}

And the related target object must define the createFromLegacy() method:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// src/Entity/User.php
namespace App\Entity;
class User
{
    public string $name = '';
    private int $legacyId = 0;

    // uses a private constructor to avoid direct instantiation
    private function __construct() {}

    public static function createFromLegacy(mixed $value, object $source): self
    {
        // $value is the initially created (empty) User object
        // $source is the LegacyUserData object
        $user = new self();
        $user->legacyId = $source->userId;

        // property mapping will happen *after* this method returns $user
        return $user;
    }
}

Mapping Multiple Targets

A source class can be configured to map to multiple different target classes. Apply the #[Map] attribute multiple times at the class level, typically using the if condition to determine which target is appropriate based on the source object's state or other logic:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// src/Dto/EventInput.php
namespace App\Dto;

use App\Entity\OnlineEvent;
use App\Entity\PhysicalEvent;
use Symfony\Component\ObjectMapper\Attribute\Map;

#[Map(target: OnlineEvent::class, if: [self::class, 'isOnline'])]
#[Map(target: PhysicalEvent::class, if: [self::class, 'isPhysical'])]
class EventInput
{
    public string $type = 'online'; // e.g., 'online' or 'physical'
    public string $title = '';

    /**
     * In class-level conditions, $value is null.
     */
    public static function isOnline(?mixed $value, object $source): bool
    {
        return 'online' === $source->type;
    }

    public static function isPhysical(?mixed $value, object $source): bool
    {
        return 'physical' === $source->type;
    }
}

// consider that the src/Entity/OnlineEvent.php and PhysicalEvent.php
// files exist and define the needed classes

// usage:
$eventInput = new EventInput();
$eventInput->type = 'physical';
$mapper = new ObjectMapper();
$event = $mapper->map($eventInput); // automatically maps to PhysicalEvent

Mapping Based on Target Properties (Source Mapping)

Sometimes, it's more convenient to define how a target object should retrieve its values from a source, especially when working with external data formats. This is done using the source parameter in the #[Map] attribute on the target class's properties.

Note that if both the source and the target classes define the #[Map] attribute, the source takes precedence.

Consider the following class that stores the data obtained form an external API that uses snake_case property names:

1
2
3
4
5
6
7
8
// src/Api/Payload.php
namespace App\Api;

class Payload
{
    public string $product_name = '';
    public float $price_amount = 0.0;
}

In your application, classes use camelCase for property names, so you can map them as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// src/Entity/Product.php
namespace App\Entity;

use App\Api\Payload;
use Symfony\Component\ObjectMapper\Attribute\Map;

// define that Product can be mapped from Payload
#[Map(source: Payload::class)]
class Product
{
    // define where 'name' should get its value from in the Payload source
    #[Map(source: 'product_name')]
    public string $name = '';

    // define where 'price' should get its value from
    #[Map(source: 'price_amount')]
    public float $price = 0.0;
}

Using it in practice:

1
2
3
4
5
6
7
8
9
10
$payload = new Payload();
$payload->product_name = 'Super Widget';
$payload->price_amount = 123.45;

$mapper = new ObjectMapper();
// map from the payload to the Product class
$product = $mapper->map($payload, Product::class);

// $product->name = 'Super Widget'
// $product->price = 123.45

When using source-based mapping, the ObjectMapper will automatically use the target's #[Map(source: ...)] attributes if no mapping is defined on the source class.

Handling Recursion

The ObjectMapper automatically detects and handles recursive relationships between objects (e.g., a User has a manager which is another User, who might manage the first user). When it encounters previously mapped objects in the graph, it reuses the corresponding target instances to prevent infinite loops:

1
2
3
4
5
6
7
8
9
10
11
12
// src/Entity/User.php
namespace App\Entity;

use App\Dto\UserDto;
use Symfony\Component\ObjectMapper\Attribute\Map;

#[Map(target: UserDto::class)]
class User
{
    public string $name = '';
    public ?User $manager = null;
}

The target DTO object defines the User class as its source and the ObjectMapper component detects the cyclic reference:

1
2
3
4
5
6
7
8
9
10
11
// src/Dto/UserDto.php
namespace App\Dto;

use Symfony\Component\ObjectMapper\Attribute\Map;

#[Map(source: \App\Entity\User::class)] // can also define mapping here
class UserDto
{
    public string $name = '';
    public ?UserDto $manager = null;
}

Using it in practice:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$manager = new User();
$manager->name = 'Alice';
$employee = new User();
$employee->name = 'Bob';
$employee->manager = $manager;
// manager's manager is the employee:
$manager->manager = $employee;

$mapper = new ObjectMapper();
$employeeDto = $mapper->map($employee, UserDto::class);

// recursion is handled correctly:
// $employeeDto->name === 'Bob'
// $employeeDto->manager->name === 'Alice'
// $employeeDto->manager->manager === $employeeDto

Custom Mapping Logic

For very complex mapping scenarios or if you prefer separating mapping rules from your DTOs/Entities, you can implement a custom mapping strategy using the ObjectMapperMetadataFactoryInterface. This allows defining mapping rules within dedicated mapper services, similar to the approach used by libraries like MapStruct in the Java ecosystem.

First, create your custom metadata factory. The following example reads mapping rules defined via #[Map] attributes on a dedicated mapper service class, specifically on its map method for property mappings and on the class itself for the source-to-target relationship:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
namespace App\ObjectMapper\Metadata;

use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\ObjectMapper\Metadata\Mapping;
use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface;
use Symfony\Component\ObjectMapper\ObjectMapperInterface;

/**
 * A Metadata factory that implements basics similar to MapStruct.
 * Reads mapping configuration from attributes on a dedicated mapper service.
 */
final class MapStructMapperMetadataFactory implements ObjectMapperMetadataFactoryInterface
{
    /**
     * @param class-string<ObjectMapperInterface> $mapperClass The FQCN of the mapper service class
     */
    public function __construct(private readonly string $mapperClass)
    {
        if (!is_a($this->mapperClass, ObjectMapperInterface::class, true)) {
            throw new \RuntimeException(sprintf('Mapper class "%s" must implement "%s".', $this->mapperClass, ObjectMapperInterface::class));
        }
    }

    public function create(object $object, ?string $property = null, array $context = []): array
    {
        try {
            $refl = new \ReflectionClass($this->mapperClass);
        } catch (\ReflectionException $e) {
            throw new \RuntimeException("Failed to reflect mapper class: " . $e->getMessage(), 0, $e);
        }

        $mapConfigs = [];
        $sourceIdentifier = $property ?? $object::class;

        // read attributes from the map method (for property mapping) or the class (for class mapping)
        $attributesSource = $property ? $refl->getMethod('map') : $refl;
        foreach ($attributesSource->getAttributes(Map::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
            $map = $attribute->newInstance();

            // check if the attribute's source matches the current property or source class
            if ($map->source === $sourceIdentifier) {
                $mapConfigs[] = new Mapping($map->target, $map->source, $map->if, $map->transform);
            }
        }

        // if it's a property lookup and no specific mapping was found, map to the same property
        if ($property && empty($mapConfigs)) {
            $mapConfigs[] = new Mapping(target: $property, source: $property);
        }

        return $mapConfigs;
    }
}

Next, define your mapper service class. This class implements ObjectMapperInterface but typically delegates the actual mapping back to a standard ObjectMapper instance configured with the custom metadata factory. Mapping rules are defined using #[Map] attributes on this class and its map method:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
namespace App\ObjectMapper;

use App\Dto\LegacyUser;
use App\Dto\UserDto;
use App\ObjectMapper\Metadata\MapStructMapperMetadataFactory;
use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\ObjectMapper\ObjectMapper;
use Symfony\Component\ObjectMapper\ObjectMapperInterface;

// define the source-to-target mapping at the class level
#[Map(source: LegacyUser::class, target: UserDto::class)]
class LegacyUserMapper implements ObjectMapperInterface
{
    private readonly ObjectMapperInterface $objectMapper;

    // inject the standard ObjectMapper or necessary dependencies
    public function __construct(?ObjectMapperInterface $objectMapper = null)
    {
        // create an ObjectMapper instance configured with *this* mapper's rules
        $metadataFactory = new MapStructMapperMetadataFactory(self::class);
        $this->objectMapper = $objectMapper ?? new ObjectMapper($metadataFactory);
    }

    // define property-specific mapping rules on the map method
    #[Map(source: 'fullName', target: 'name')] // Map LegacyUser::fullName to UserDto::name
    #[Map(source: 'creationTimestamp', target: 'registeredAt', transform: [\DateTimeImmutable::class, 'createFromFormat'])]
    #[Map(source: 'status', if: false)] // Ignore the 'status' property from LegacyUser
    public function map(object $source, object|string|null $target = null): object
    {
        // delegate the actual mapping to the configured ObjectMapper
        return $this->objectMapper->map($source, $target);
    }
}

Finally, use your custom mapper service:

1
2
3
4
5
6
7
8
9
10
11
12
use App\Dto\LegacyUser;
use App\ObjectMapper\LegacyUserMapper;

$legacyUser = new LegacyUser();
$legacyUser->fullName = 'Jane Doe';
$legacyUser->status = 'active'; // this will be ignored

// instantiate your custom mapper service
$mapperService = new LegacyUserMapper();

// use the map method of your service
$userDto = $mapperService->map($legacyUser); // Target (UserDto) is inferred from #[Map] on LegacyUserMapper

This approach keeps mapping logic centralized within dedicated services, which can be beneficial for complex applications or when adhering to specific architectural patterns.

Advanced Configuration

The ObjectMapper constructor accepts optional arguments for advanced usage:

  • ObjectMapperMetadataFactoryInterface $metadataFactory: Allows custom metadata factories, such as the one shown in the MapStruct-like example. The default is ReflectionObjectMapperMetadataFactory, which uses #[Map] attributes from source and target classes.
  • ?PropertyAccessorInterface $propertyAccessor: Lets you customize how properties are read and written to the target object, useful for accessing private properties or using getters/setters.
  • ?ContainerInterface $transformCallableLocator: A PSR-11 container (service locator) that resolves service IDs referenced by the transform option in #[Map].
  • ?ContainerInterface $conditionCallableLocator: A PSR-11 container for resolving service IDs used in if conditions within #[Map].

These dependencies are automatically configured when you use the ObjectMapperInterface service provided by Symfony.

This work, including the code samples, is licensed under a Creative Commons BY-SA 3.0 license.
TOC
    Version