- Регистрация
- 1 Мар 2015
- Сообщения
- 1,481
- Баллы
- 155
I saw an 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
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 and .
And the first thing I thought was does this work in Laravel with Eloquent models. So I started to experiment.
The setup
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 and .