DEV Community

david duymelinck
david duymelinck

Posted on

3

Using Symfony Object Mapper for Laravel Models

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);
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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

}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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)

👋 Kindness is contagious

Discover fresh viewpoints in this insightful post, supported by our vibrant DEV Community. Every developer’s experience matters—add your thoughts and help us grow together.

A simple “thank you” can uplift the author and spark new discussions—leave yours below!

On DEV, knowledge-sharing connects us and drives innovation. Found this useful? A quick note of appreciation makes a real impact.

Okay