1

is it possible to implement a custom hydration and persistence in Doctrine 2 on a per entity basis?

Doctrine 2 has some major limitations regarding value objects (e.g. collections and ids). I wonder if it would be possible to use custom mechanisms (or implementations) for the mapping from object properties to the database (loading and persistence).

I know there are some possibilities to "solve" this problem but I like none of them:

  • Fake entities require proper handling in the entity which leaks the persistence layer into the domain objects
  • real entities require a lot more work in persistence (more repositories and more complex handling)
  • Embaddables have the mentioned limitations
  • Custom DBAL types with serialization makes querying for certain values impossible or at least extremely slow

I know there are the lifecycle events in doctrine which may be usable. I could't find out if the postLoad event carries an already constructed entity object (with all the VOs)? Becuase in that case it would be useless to me.

best regards, spigandromeda

SpigAndromeda
  • 174
  • 1
  • 11

1 Answers1

1

Yes, you can register new hydrators in your config/packages/doctrine.yaml like this:

doctrine:
    dbal: ...
    orm:
        hydrators:
            CustomEntityHydrator: 'App\ORM\Hydrator\CustomEntityHydrator'
            ...
        mapping: ...
        ...

You can then use it in your queries like this:

public function findCustomEntities(): array
{
    return $this->createQueryBuilder('c')
        ...your query logic...
        ->getResult('CustomEntityHydrator');
}

Note, that you can only specify which hydrator you want to use for the root entity. If you fetch associated entities you might end up with a more complicated setup that is hard to debug.

Instead you could consider dealing with value objects (VOs) only in the interface of your entity. In other words, the fields are scalar values, but your method arguments and return values are VOs.

Here is an example with an entity that has a id of type Uuid, a location (some numeric identifier), status (e.g. ternary true/false/null). These are only there to showcase how to deal with different type of value objects:

/**
 * @ORM\Entity()
 */
class CustomEntity
{
    /**
     * @ORM\Id()
     * @ORM\Column(type="string", length=64)
     */
    private string $id;

    /**
     * @ORM\Column(type="int")
     */
    private int $location;

    /**
     * @ORM\Column(type="bool, nullable=true)
     */
    private bool $status;

    private function __construct(Uuid $id, Location $location, Status $status)
    {
        $this->id = (string) $id;
        $this->location = $location->getValue();
        $this->status = $status->get();
    }

    public static function new(Location $location, Status $status): self
    {
        return new self(Uuid::v4(), $location, $status);
    }

    public function getId(): Uuid
    {
        return Uuid::fromString($this->id);
    }

    public function getLocation(): Location
    {
        return new Location($this->location);
    }

    public function activate(): void
    {
        $this->status = true;
    }

    public function deactivate(): void
    {
        $this->status = false;
    }

    public function isActive(): bool
    {
        $this->status === true;
    }

    public function isInactive(): bool
    {
        $this->status === false;
    }

    public function isUninitialized(): bool
    {
        $this->status === null;
    }

    public function getStatus(): Status
    {
        if ($this->status === null) {
            return new NullStatus();
        }
        if ($this->status === true) {
            return new ActiveStatus();
        }

        return new InactiveStatus();
    }
}

As you can see, you could replace new() with a public constructor. It would work similar with setters. I sometimes even use (private) setters for this in the constructor. In case of the status you don't even need setters if you instead use multiple methods that set the value internally. Similarly you might want to return scalar values instead of a VO in some cases (or the other way around as shown with the status getter and issers).

The point is, your entity looks from the outside as if it would use your VOs, but internally it already switches to a representation that works better with Doctrine ORM. You could even mix this with using VOs and custom types, e.g. for the UUID. You just have to be careful, when your VO needs more info for being constructed than you want to store in the database, e.g. if the numeric location in our example would also use a locale during creation, then we would need to store this (which makes sense as it seems to be related to the numeric id) or we have to hardcode it in the entity or add an abstraction above, that has access to the locale, in which case your entity would likely not return a Location or at least not a LocalizedLocation.

You might also want to consider not having a VO for each and every property in your entity. While it definitely can be helpful, e.g. to wrap an Email into a custom VO to ensure validity instead of just type hinting for string, it might be less useful for something as generic as a (user's) name, which should be very lenient with which strings it accepts as there are a wide variety of names. Using the approach above you can easily introduce a VO later, by adding a new getter for the VO, changing new() or any other method that mutates your property and then not having to change anything in the data model below (unless there is a more drastic change to how the value is represented).

dbrumann
  • 16,803
  • 2
  • 42
  • 58
  • Thx. Very nice answer and I hope other people will read this as well. I think the Doctrine ORM Github issues about VO IDs and collection of VOs got a lot of attention. It seems to me that Doctrine ORM 3 won't be released anaytime soon. Your apporoach has another advantage. Doctrine also doesn't support nullable Embaddables. With the setter/getter Interfacing this would also be possible! Really nice solution, though! – SpigAndromeda Dec 29 '20 at 23:32