symfonycasts / micro-mapper
A tiny, underwhelming data mapper to map one object to another!
Installs: 147 136
Dependents: 0
Suggesters: 0
Security: 0
Stars: 61
Watchers: 9
Forks: 4
Open Issues: 5
Requires
- php: >=8.1
Requires (Dev)
- phpstan/phpstan: ^1.10.39
- phpunit/phpunit: ^9.6
- symfony/filesystem: ^6.3|^7.0
- symfony/framework-bundle: ^6.3|^7.0
- symfony/phpunit-bridge: ^6.3.9|^7.0
README
Need to map one object (e.g. a Doctrine entity) to another object (e.g. a DTO) and love writing the mapping code manually? Then this library is for you!
Define a "mapper" class:
use App\Entity\Dragon; use App\DTO\DragonDTO; #[AsMapper(from: Dragon::class, to: DragonDTO::class)] class DragonEntityToDtoMapper implements MapperInterface { public function load(object $from, string $toClass, array $context): object { $entity = $from; return new DragonDTO($entity->getId()); } public function populate(object $from, object $to, array $context): object { $entity = $from; $dto = $to; $dto->name = $entity->getName(); $dto->firePower = $entity->getFirePower(); return $dto; } }
Then... map!
$dragon = $dragonRepository->find(1); $dragonDTO = $microMapper->map($dragon, DragonDTO::class);
MicroMapper is similar to other data mappers, like jane-php/automapper, except... less impressive! Jane's Automapper is awesome and handles a lot of heavy lifting. With MicroMapper, you do the heavy lifting. Let's review with a table!
Support us & Symfony
Is this package useful! We're thrilled 😍!
A lot of time & effort from the Symfonycasts team & the Symfony community goes into creating and maintaining these packages. You can support us + Symfony (and learn a bucket-load) by grabbing a subscription to SymfonyCasts!
Installation
composer require symfonycasts/micro-mapper
If you're using Symfony, you're done! If not, see Stand-alone Library Setup.
Usage
Suppose you have a Dragon
entity, and you want to map it to a
DragonApi
object (perhaps to use with API Platform, like we do
in our Api Platform EP3 Tutorial).
Step 1: Create the Mapper Class
To do this, create a "mapper" class that defines how to map:
namespace App\Mapper; use App\Entity\Dragon; use App\ApiResource\DragonApi; use Symfonycasts\MicroMapper\AsMapper; use Symfonycasts\MicroMapper\MapperInterface; #[AsMapper(from: Dragon::class, to: DragonApi::class)] class DragonEntityToApiMapper implements MapperInterface { public function load(object $from, string $toClass, array $context): object { $entity = $from; assert($entity instanceof Dragon); // helps your editor know the type return new DragonApi($entity->getId()); } public function populate(object $from, object $to, array $context): object { $entity = $from; $dto = $to; // helps your editor know the types assert($entity instanceof Dragon); assert($dto instanceof DragonApi); $dto->name = $entity->getName(); $dto->firePower = $entity->getFirePower(); return $dto; } }
The mapper class has three parts:
#[AsMapper]
attribute: defines the "from" and "to" classes (needed for Symfony usage only).load()
method: creates/loads the "to" object - e.g. load it from the database or create it and populate just the identifier.populate()
method: populates the "to" object with data from the "from" object.
Step 2: Use the MicroMapper Service
To use the mapper, you can fetch the MicroMapperInterface
service. For
example, from a controller:
<?php namespace App\Controller; use App\Entity\Dragon; use App\ApiResource\DragonApi; use Symfonycasts\MicroMapper\MicroMapperInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Routing\Annotation\Route; class DragonController extends AbstractController { #[Route('/dragons/{id}', name: 'api_dragon_get_collection')] public function index(Dragon $dragon, MicroMapperInterface $microMapper) { $dragonApi = $microMapper->map($dragon, DragonApi::class); return $this->json($dragonApi); } }
Reverse Transforming
To do the reverse transformation - DragonApi
to Dragon
- it's
the same process: create a mapper class:
The mapper:
namespace App\Mapper; use App\ApiResource\DragonApi; use App\Entity\Dragon; use App\Repository\DragonRepository; use Symfonycasts\MicroMapper\AsMapper; use Symfonycasts\MicroMapper\MapperInterface; #[AsMapper(from: DragonApi::class, to: Dragon::class)] class DragonApiToEntityMapper implements MapperInterface { public function __construct(private DragonRepository $dragonRepository) { } public function load(object $from, string $toClass, array $context): object { $dto = $from; assert($dto instanceof DragonApi); return $dto->id ? $this->dragonRepository->find($dto->id) : new Dragon(); } public function populate(object $from, object $to, array $context): object { $dto = $from; $entity = $to; assert($dto instanceof DragonApi); assert($entity instanceof Dragon); $entity->setName($dto->name); $entity->setFirePower($dto->firePower); return $entity; } }
In this case, the load()
method fetches the Dragon
entity from the
database if it has an id
property.
Handling Nested Objects
If you have nested objects, you can use the MicroMapperInterface
to map
those too. Suppose the Dragon
entity has a treasures
property
that is a OneToMany
relation to Treasure
entity. And in DragonApi
, we have
a treasures
property that should hold an array of TreasureApi
objects.
First, create a mapper for the Treasure
-> TreasureApi
mapping:
// ... #[AsMapper(from: Treasure::class, to: TreasureApi::class)] class TreasureEntityToApiMapper implements MapperInterface { public function load(object $from, string $toClass, array $context): object { return new TreasureApi($from->getId()); } public function populate(object $from, object $to, array $context): object { $entity = $from; $dto = $to; // ... map all the properties return $dto; } }
Next, in the DragonEntityToApiMapper
, use the MicroMapperInterface
to map the
Treasure
objects to TreasureApi
objects:
namespace App\Mapper; // ... use App\ApiResource\TreasureApi; use Symfonycasts\MicroMapper\MicroMapperInterface; #[AsMapper(from: Dragon::class, to: DragonApi::class)] class DragonEntityToApiMapper implements MapperInterface { public function __construct(private MicroMapperInterface $microMapper) { } // load() is the same public function populate(object $from, object $to, array $context): object { $entity = $from; $dto = $to; // ... other properties $treasuresApis = []; foreach ($entity->getTreasures() as $treasureEntity) { $treasuresApis[] = $this->microMapper->map($treasureEntity, TreasureApi::class, [ MicroMapperInterface::MAX_DEPTH => 1, ]); } $dto->treasures = $treasuresApis; return $dto; } }
That's it! The result will be a DragonApi
object with a treasures
property
that holds an array of TreasureApi
objects.
MAX_DEPTH & Circular References
Imagine now that TreasureEntityToApiMapper
also maps a dragon
property on the TreasureApi
object:
// ... #[AsMapper(from: Treasure::class, to: TreasureApi::class)] class TreasureEntityToApiMapper implements MapperInterface { public function __construct(private MicroMapperInterface $microMapper) { } // load() public function populate(object $from, object $to, array $context): object { $entity = $from; $dto = $to; // ... map all the properties $dto->dragon = $this->microMapper->map($entity->getDragon(), DragonApi::class, [ MicroMapperInterface::MAX_DEPTH => 1, ]); return $dto; } }
This creates a circular reference: the Dragon
entity is mapped to a
DragonApi
object... which then maps its treasures
property to an array
of TreasureApi
objects... which then each map their dragon
property to a
DragonApi
object... forever... and ever... and ever...
The MAX_DEPTH
option tells MicroMapper how many levels deep to
go when mapping, and you usually want to set this to 0 or 1 when mapping a
relation.
When the max depth is hit, the load()
method will be called on the mapper
for that level but populate()
will not be called. This results in a
"shallow" mapping of the final level object.
Let's look at a few depth examples using this code:
$dto->dragon = $this->microMapper->map($dragonEntity, DragonApi::class, [ MicroMapperInterface::MAX_DEPTH => ???, ]);
MAX_DEPTH = 0
: Because the depth is immediately hit, theDragon
entity will be mapped to aDragonApi
object by calling theload()
method onDragonEntityToApiMapper
. But thepopulate()
method will not be called. This means that the finalDragonApi
object will have anid
but no other data.
Result:
DragonApi: id: 1 name: null firePower: null treasures: []
MAX_DEPTH = 1
: TheDragon
entity will be fully mapped to aDragonApi
object: both theload()
andpopulate()
methods will be called on its mapper like normal. However, when eachTreasure
inDragon.treasures
is mapped to aTreasureApi
object, this will be "shallow": theTreasureApi
object will have anid
property but no other data (because the max depth was hit and so onlyload()
is called onTreasureEntityToApiMapper
).
Result:
DragonApi: id: 1 name: 'Sizzley Pete' firePower: 100 treasures: TreasureApi: id: 1 name: null value: null dragon: null TreasureApi: id: 2 name: null value: null dragon: null
In something like API Platform, you can also use MAX_DEPTH
to limit the
depth of the serialization for performance. For example, if the TreasureApi
object has a dragon
property that is expressed as the IRI string (e.g.
/api/dragons/1
), then setting MAX_DEPTH
to 0
is enough and prevents
extra mapping work.
Settable Collection Relations on Entities
In our example, the Dragon
entity has a treasures
property that is a
OneToMany
relation to the Treasure
entity. Our DTO classes have
the same relation: DragonApi
holds an array of TreasureApi
objects.
Those greedy dragons!
If you want to map a DragonApi
object to the Dragon
entity and
the DragonApi.treasures
property may have changed, you need to
update the Dragon.treasures
properly carefully.
For example, this will not work:
// ... #[AsMapper(from: DragonApi::class, to: Dragon::class)] class DragonApiToEntityMapper implements MapperInterface { // ... public function populate(object $from, object $to, array $context): object { $dto = $from; $entity = $to; // ... $treasureEntities = new ArrayCollection(); foreach ($dto->treasures as $treasureApi) { $treasureEntities[] = $this->microMapper->map($treasureApi, Treasure::class, [ // depth=0 because we really just need to load/query each Treasure entity MicroMapperInterface::MAX_DEPTH => 0, ]); } // !!!!! THIS WILL NOT WORK !!!!! $entity->setTreasures($treasureEntities); return $entity; } }
The problem is with the $entity->setTreasures()
call. In fact, this method probably
doesn't even exist on the Dragon
entity! Instead, it likely has addTreasure()
and
removeTreasure()
methods and these must be called instead so that the "owning"
side of the Doctrine relationship is correctly set (otherwise the changes won't save).
An easy way to do this is with the PropertyAccessorInterface
service:
// ... use Symfony\Component\PropertyAccess\PropertyAccessorInterface; #[AsMapper(from: DragonApi::class, to: Dragon::class)] class DragonApiToEntityMapper implements MapperInterface { public function __construct( private MicroMapperInterface $microMapper, private PropertyAccessorInterface $propertyAccessor ) { } // ... public function populate(object $from, object $to, array $context): object { $dto = $from; $entity = $to; // ... $treasureEntities = []; foreach ($dto->treasures as $treasureApi) { $treasureEntities[] = $this->microMapper->map($treasureApi, Treasure::class, [ MicroMapperInterface::MAX_DEPTH => 0, ]); } // this will call the addTreasure() and removeTreasure() methods $this->propertyAccessor->setValue($entity, 'treasures', $treasureEntities); return $entity; } }
Standalone Library Setup
If you're not using Symfony, you can still use MicroMapper! You'll need to
instantiate the MicroMapper
class and pass it all of your mappings:
$microMapper = new MicroMapper([]); $microMapper->addMapperConfig(new MapperConfig( from: Dragon::class, to: DragonApi::class, fn() => new DragonEntityToApiMapper($microMapper) )); $microMapper->addMapperConfig(new MapperConfig( from: DragonApi::class, to: Dragon::class, fn() => new DragonApiToEntityMapper($microMapper) )); // now it's ready to use!
In this case, the #[AsMapper]
attribute is not needed.
Credits
License
MIT License (MIT): see the License File for more details.