Introduction
As we saw in object calisthenics and generally, it is a good practice to use Value Objects to encapsulate the types of our domain. Language types are often too generic for domain modeling: an email address is always a string, but an arbitrary string is not an email address.
Our domain state is saved in a persistence system, usually (but not always) a relational database such as MySQL or PostgreSQL. When using PHP and Symfony, Doctrine is the go-to library to manage the persistence layer.
Doctrine Custom Types
However, when we use Value Objects in our Domain, Doctrine does not know how to convert our types to the databases'. To solve this problem, Doctrine allows the creation of custom mapping types. The task of these custom types is to convert in both directions: from PHP to the database and vice versa. We need to create as many Doctrine types Value Objects we have in our application.
For example, suppose we have the following Value Object that represents an email address:
<?php
declare(strict_types=1);
namespace rubenrubiob\User\Domain\ValueObject;
use rubenrubiob\User\Domain\Exception\ValueObject\ EmailIsNotValid;
final readonly class Email
{
private function __construct(private string $email)
{
}
/** @throws EmailIsNotValid */
public static function create(string $email): self
{
self::validate($email);
return new self($email);
}
public function toString(): string
{
return $this->email;
}
/** @throws EmailIsNotValid */
private static function validate(string $email): void
{
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
throw EmailIsNotValid::create($email);
}
}
}
We could then create a Doctirne's custom type as follows. It is important to note that each custom type should have a unique name within our application:
<?php
declare(strict_types=1);
namespace rubenrubiob\User\Infrastructure\Persistence\Doctrine\ValueObject;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\ConversionException;
use Doctrine\DBAL\Types\StringType;
use rubenrubiob\User\Domain\Exception\ValueObject\ EmailIsNotValid;
use rubenrubiob\User\Domain\ValueObject\Email;
final class Email extends StringType
{
public const NAME = 'email';
/** @throws ConversionException */
public function convertToDatabaseValue($value, AbstractPlatform $platform): mixed
{
if ($value === null) {
return null;
}
if ($value instanceof Email) {
return parent::convertToDatabaseValue($value->toString(), $platform);
}
throw ConversionException::conversionFailedInvalidType(
$value,
$this->getName(),
['null', Email::class],
);
}
/** @throws ConversionException */
public function convertToPHPValue($value, AbstractPlatform $platform): Email|null
{
/** @var string|null $value */
$value = parent::convertToPHPValue($value, $platform);
if ($value === null) {
return null;
}
try {
return Email::create($value);
} catch (EmailIsNotValid $e) {
throw ConversionException::conversionFailed(
$value,
$this->getName(),
$e,
);
}
}
public function requiresSQLCommentHint(AbstractPlatform $platform): bool
{
return true;
}
public function getName(): string
{
return self::NAME;
}
}
Configuration
Each custom type must be registered in Doctrine. In a Symfony application, it can be done in Doctrine's configuration file. Using YAML, we need to use the custom type's name as a key and its namespace as a value. With the previous example, we could have the following configuration:
# config/packages/doctrine.yaml
doctrine:
dbal:
types:
email: rubenrubiob\User\Infrastructure\Persistence\Doctrine\ValueObject\EmailType
Nonetheless, as our application grows, we will have plenty of Value Objects, each with its own custom Doctrine type. Having to register each one of them could be tedious and prone to errors, as the developer may forget to register them.
To solve this problem, we could implement a Symfony CompilerPass
that automatically registers all our custom types. This solution does not have any performance impact in a production environment, as Symfony's kernel is compiled and cached when deployed, so it is not rebuilt with each request.
CompilerPass
When using the symfony/doctrine-bridge
package in a Symfony application (bundled when using the framework), there exists a container parameter with the definition of custom Doctrine types: doctrine.dbal.connection_factory.types
. It is an associative array
with the type: array<string, array{class: class-string}>
, being the key the type name.
Thus, we can write a CompilerPass
that iterates over all our custom Doctrine types, and registers them in that parameter. To do so, we could use league/construct-finder
, a library that allows us to locate classes within our source code.
The steps to implement the CompilerPass
are:
- Iterate over all classes within our
src
folder. - Filter the namespace of all custom Doctrine types: all types must be placed in a namespace that matches a regular expression.
- For each type, we try to get its name using reflection and check that the class has a constant called
NAME
. - Add each type to the container parameter array, then set it again in the container.
<?php
declare(strict_types=1);
namespace rubenrubiob\Shared\Infrastructure\Symfony\DependencyInjection;
use Generator;
use League\ConstructFinder\ConstructFinder;
use ReflectionClass;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use function is_string;
use function Safe\preg_match;
use function sprintf;
final readonly class DoctrineTypeRegisterCompilerPass implements CompilerPassInterface
{
private const CONTAINER_TYPES_PARAMETER = 'doctrine.dbal.connection_factory.types';
private const PROJECT_TYPES_PATTERN = '/Infrastructure\\\\Persistence\\\\Doctrine\\\\ValueObject(\\\\(.*))?/i';
private const TYPE_NAME_CONSTANT_NAME = 'NAME';
private const SRC_FOLDER_MASK = '%s/src';
public function __construct(
private string $projectDir,
) {
}
public function process(ContainerBuilder $container): void
{
/** @var array<string, array{class: class-string}> $typeDefinition */
$typeDefinition = $container->getParameter(self::CONTAINER_TYPES_PARAMETER);
$types = $this->generateTypes();
/** @var array{namespace: string, name: string} $type */
foreach ($types as $type) {
$name = $type['name'];
$namespace = $type['namespace'];
if (array_key_exists($name, $typeDefinition)) {
continue;
}
$typeDefinition[$name] = ['class' => $namespace];
}
$container->setParameter(self::CONTAINER_TYPES_PARAMETER, $typeDefinition);
}
/** @return Generator<int, array{namespace: class-string, name: string}> */
private function generateTypes(): iterable
{
$srcFolder = sprintf(self::SRC_FOLDER_MASK, $this->projectDir);
$classNames = ConstructFinder::locatedIn($srcFolder)->findClassNames();
foreach ($classNames as $className) {
if (preg_match(self::PROJECT_TYPES_PATTERN, $className) === 0) {
continue;
}
$reflection = new ReflectionClass($className);
if (! $reflection->hasConstant(self::TYPE_NAME_CONSTANT_NAME)) {
continue;
}
$constantValue = $reflection->getConstant(self::TYPE_NAME_CONSTANT_NAME);
if (! is_string($constantValue)) {
continue;
}
yield [
'namespace' => $reflection->getName(),
'name' => $constantValue,
];
}
}
}
CompilerPass
registering
The last step consists of adding the CompilerPass
to Symfony's Kernel
:
<?php
declare(strict_types=1);
namespace rubenrubiob\Shared\Infrastructure\Symfony;
use rubenrubiob\Shared\Infrastructure\Symfony\DependencyInjection\ DoctrineTypeRegisterCompilerPass;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
protected function build(ContainerBuilder $container): void
{
$container->addCompilerPass(
new DoctrineTypeRegisterCompilerPass(
$this->getContainer()->getParameter('kernel.project_dir')
)
);
}
}
After this step, we will have all our custom Doctrine types registered in our application automatically.
Summary
We saw how we can create custom Doctrine types to use Value Objects transparently for the persistence layer of our application. We reviewed how to register them in a Symfony configuration file. However, to ease this task and prevent errors, we implemented a CompilerPass
that automatically registers all these custom Doctrine types in our Symfony application.
Top comments (5)
Registering a Doctrine type for a simple VO is overkill: just embed the object.
What do you mean? What is your approach to register custom Doctrine types?
I mean in the example you propose it's much simpler to map the Email VO as embeddable and embed it in an entity.
The rest of your article is still valid for Doctrine types, which can be very useful for more complex VOs.
I see what you mean now. I had not thought about it before, but it certainly is an interesting approach, as it reduces the code to maintain. I will try it in my next project for sure!
Did you find any issue when using this approach?
Thank you for your comment!
The limit of embedding is that works well only with simple VOs, usually the ones composed by a single property (like Email in your example).
This is because there's no way to allow for dynamic nullability, so your VO has either nullable properties or non-nullable properties.
For instance, I tried for years to map moneyphp/money objects using embeddables, always with unsatisfying results. Once a I tried the Doctrine type approach, it fitted perfectly.