I saw an Object Mapper component is added to Symfony 7.3.
And the first thing I thought was does this work in Laravel with Eloquent models. So I started to experiment.
The setup
I'm using PHP 8.4 and Laravel 12.
Run composer require symfony/object-mapper symfony/property-access
to get the needed dependencies.
First attempt
For the people who don't know how Eloquent models work internally. Instead of properties a model class uses an attributes array to identify the fields.
I assumed that a custom class that extends PropertyAccessorInterface
would be sufficient, because I saw this code in the ObjectMapper
class;
$this->propertyAccessor ? $this->propertyAccessor->setValue($mappedTarget, $property, $value) : ($mappedTarget->{$property} = $value);
So I created the class
use Illuminate\Database\Eloquent\Model;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\PropertyAccess\PropertyPathInterface;
class ModelPropertyAccessor implements PropertyAccessorInterface
{
/**
* @inheritDoc
*/
public function setValue(object|array &$objectOrArray, PropertyPathInterface|string $propertyPath, mixed $value): void
{
$objectOrArray->{$propertyPath} = $value;
}
/**
* @inheritDoc
*/
public function getValue(object|array $objectOrArray, PropertyPathInterface|string $propertyPath): mixed
{
if($objectOrArray instanceof Model) {
return $objectOrArray->getAttribute($propertyPath);
}
return $objectOrArray->{$propertyPath} ;
}
/**
* @inheritDoc
*/
public function isWritable(object|array $objectOrArray, PropertyPathInterface|string $propertyPath): bool
{
return true;
}
/**
* @inheritDoc
*/
public function isReadable(object|array $objectOrArray, PropertyPathInterface|string $propertyPath): bool
{
return true;
}
}
And I created a class that extends the ObjectMapperInterface
to make it easier to map the values.
use Symfony\Component\ObjectMapper\ObjectMapper;
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
class ModelMapper implements ObjectMapperInterface
{
private readonly ObjectMapperInterface $objectMapper;
public function __construct()
{
$this->objectMapper = new ObjectMapper(propertyAccessor: new ModelPropertyAccessor());
}
/**
* @inheritDoc
*/
public function map(object $source, object|string|null $target = null): object
{
return $this->objectMapper->map($source, $target);
}
}
Next step was a utility DTO because most Laravel output returns an array.
abstract class ModelInputDTO
{
public static function fromArray(array $input)
{
$reflection = new \ReflectionClass(static::class);
$instance = new static;
$properties = $reflection->getProperties(\ReflectionProperty::IS_PUBLIC);
foreach ($properties as $property) {
$propertyName = $property->getName();
if (array_key_exists($propertyName, $input)) {
$instance->{$propertyName} = $input[$propertyName];
}
}
return $instance;
}
}
Then I created a model DTO.
use App\DTO\ModelInputDTO;
use App\Models\Product;
use Symfony\Component\ObjectMapper\Attribute\Map;
#[Map(target: Product::class)]
class ProductInputDTO extends ModelInputDTO
{
#[Map(target: 'name')]
public string $fullName;
}
As a test I wanted to have a different name property in the DTO than in the $fillable
model array.
The code in the controller is;
$productData = ProductInputDTO::fromArray(['fullName' => 'me']);
$mapper = new ModelMapper();
$product = $mapper->map($productData);
The expectation is that $product->getAttributes()
['name' => 'me']
returns.
Of course it didn't. So I took a better look at the code, and i discovered that there are two places in the map
method where $targetRefl->hasProperty()
is used.
To make a custom PropertyAccessorInterface
class work these lines need to be changed.
Second attempt
I rewrote the ObjectMapperInterface
class.
use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\ObjectMapper\ObjectMapper;
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
class ModelMapper implements ObjectMapperInterface
{
private readonly ObjectMapperInterface $objectMapper;
public function __construct()
{
$this->objectMapper = new ObjectMapper();
}
/**
* @inheritDoc
*/
public function map(object $source, object|string|null $target = null): object
{
$mappedTarget = $this->objectMapper->map($source, $target);
$fillables = $mappedTarget->getFillable();
$sourceProperties = (new \ReflectionClass($source))->getProperties();
foreach ($sourceProperties as $property) {
$propertyName = $property->getName();
if(in_array($propertyName, $fillables)) {
$mappedTarget->{$propertyName} = $source->{$propertyName};
} else {
$attributes = $property->getAttributes(Map::class);
foreach ($attributes as $attribute) {
$map = $attribute->newInstance();
if(in_array($map->target, $fillables)) {
$mappedTarget->{$map->target} = $source->{$propertyName};
}
}
}
}
return $mappedTarget;
}
}
In the map
method I use the parent method to get the target instance.
And I use the content of the $fillable
model property to check the DTO values.
This is not the solution for all the features the Object mapper component offers. But it is good enough for my experiment.
Conclusion
Using the Object mapper component for Eloquent models is at the moment too much code to be a go to.
But it shouldn't stop you to use it for other classes.
I didn't even get to the great features of the class like conditions and mapping multiple targets.
Top comments (0)