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).