Auto-registering custom Doctrine types in Symfony
Tags: php, symfony, doctrine, devex
Published: 2023-08-30 08:03:08

## 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](https://www.doctrine-project.org/projects/doctrine-dbal/en/current/reference/types.html#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
<?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
<?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](https://symfony.com/doc/current/doctrine/dbal.html#registering-custom-mapping-types). 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:

```yml
# 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`](https://symfony.com/doc/current/service_container/compiler_passes.html) 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`](https://github.com/thephpleague/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
<?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
<?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.