DEV Community

Cover image for Auto-registering custom Doctrine types in Symfony
Rubén Rubio
Rubén Rubio

Posted on

Auto-registering custom Doctrine types in Symfony

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
garak profile image
Massimiliano Arione

Registering a Doctrine type for a simple VO is overkill: just embed the object.

Collapse
 
rubenrubiob profile image
Rubén Rubio

What do you mean? What is your approach to register custom Doctrine types?

Collapse
 
garak profile image
Massimiliano Arione

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.

Thread Thread
 
rubenrubiob profile image
Rubén Rubio

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!

Thread Thread
 
garak profile image
Massimiliano Arione

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.