PHP 8.1 added Enums, this allows us to set a defined number of possible values for an array. It is a very interesting way of making sure only the values we want can be to a specific value.

So I started to play around with them, A while back and ran into a few issues with Doctrine and the Symfony FormType.

Let's dive into it !

Enums

I wrote a small program to generate browser screen shots when given a certain configuration. This uses browsershot. I wanted a web page with a form for all the different configuration options, one of them is the output type.

So I created an Enum FileType:

<?php

namespace App\Enum;

enum FileType: string
{
    case PNG = 'image/png';
    case JPEG = 'image/jpeg';
    case PDF = 'application/pdf';
}

There are 3 cases that have a value, this is what is called a Backed Enum. You can access these values on an Enum by simply calling the value property.

$fileType = FileType::PNG;
$mimeType = $fileType->value; // 'image/png'

With this I wanted to add some simple functions to check what the file extension will be when a certain type is used and to check if the selected value is an image.

enum FileType: string
{
...
    public static function isImage(?self $value): bool
    {
        return match ($value) {
            self::PDF, null => false,
            self::PNG, self::JPEG => true,
        };
    }

    public static function getExtension(?self $value): ?string
    {
        return match ($value) {
            self::PDF => 'pdf',
            self::PNG => 'png',
            self::JPEG => 'jpeg',
        };
    }
}

I can now use these easily in my Symfony Controller to set the filename when downloading the file.

#[Route('/shot/{shot}/file', name: 'shot_file')]
public function getShotFile(Shot $shot, Request $request): StreamedResponse
{
    ...
    $fileType = $configuration->getFileType();
    $mimeType = $fileType->value;
    $extension = FileType::getExtension($fileType);
    $filename = 'browserShot-' . (new \DateTime())->getTimestamp() . '.' . $extension;
    ...
}

And in my Twig templates.

{% if shotConfiguration.isImage %}
<img src="{{ shot.base64 }}">
{% else %}
<iframe src="{{ shot.base64 }}"></iframe>
{% endif %}

It is nice to be able to easily access these functions on anything that will use this Enum as a property. The next thing I needed was an easy way to get all the values and the cases of an Enum for error messages, so I added a few more functions.

public static function getCases(): array
{
    $cases = self::cases();
    return array_map(static fn(UnitEnum $case) => $case->name, $cases);
}

public static function getValues(): array
{
    $cases = self::cases();
    return array_map(static fn(UnitEnum $case) => $case->value, $cases);
}

Now in my Symfony command I can add an error if the fileType that is being set is not part of the available ones.

$io->error(sprintf('The file type you provided, %s, is not part of the available file types : [%s]', $fileTypeInput, implode(', ',FileType::getValues())));

Or the cases if that is what the command is expecting.

->addOption('format', null, InputOption::VALUE_OPTIONAL, 'Set the window to a format, allowed formats are ' . implode(', ',PaperFormat::getCases()))

With this I now have a nice command to generate the screenshots, that only accepts certain parameters, and that will return useful errors. I now wanted to work on a form that has a choice list for the Enums.

EnumType

When generating a form with Symfony it offers many FormTypes. One of them is the EnumType that is part of the Choice Fields. Thanks to this it is easy to add a list of the available cases to a form.

class ShotConfigurationType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('fileType', EnumType::class, ['class' => FileType::class])
    }
}

Now in my form I will have a list of all the cases of my Enum that the user can select. This is already great, but I wanted to not just show the cases that are all in capitals and, for me, not there for the final user but for the code side.

So I wanted to find a way to show the values and not the cases to the final user. This took a bit of searching around. I ended up diving into the Symfony EnumType file located at vendor/symfony/form/Extension/Core/Type/EnumType.php And saw the following :

->setDefault('choice_label', static function (\UnitEnum $choice): string {
    return $choice->name;
})

By default, the choice_label, the values in the dropdown list, are set the name of the enum. This makes sense as an Enum is not required to have a value. So now I can override this in my FormType to render the value instead.

->add('fileType', EnumType::class, ['class' => FileType::class,
   'choice_label' => static function (\UnitEnum $choice): string {
        return $choice->value;
}])

The form is all good now. Next we need to be able to save and retrieve the Enum from our Database.

Doctrine

When using Symfony we are used to Doctrine saving and retrieving our entities. It takes care of transforming our entity to something the database can handle and when we query something from it, it will return the entity with all the correct types on its properties.

When an entity has a DateTime as a property Doctrine will store it as such in the projects' database. An Enum is not something Doctrine can understand on his own, we need to give it some help. I ended up finding an article that explains how to use our Enums as a Doctrine Type.

In the database the Enum is not a class but simply a string. We need to tell it how to convert our PHP class to a value that the database can understand and vice-versa. Therefor we can create a new Type that extends the base Doctrine\DBAL\Types\Type.

abstract class AbstractEnumType extends Type
{
    public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
    {
        return 'TEXT';
    }

    public function convertToDatabaseValue($value, AbstractPlatform $platform)
    {
        if ($value instanceof BackedEnum) {
            return $value->value;
        }
        return null;
    }

    public function convertToPHPValue($value, AbstractPlatform $platform)
    {
        if (false === enum_exists($this::getEnumsClass(), true)) {
            throw new LogicException("This class should be an enum");
        }
        // πŸ”₯ https://www.php.net/manual/en/backedenum.tryfrom.php
        return $this::getEnumsClass()::tryFrom($value);
    }

    abstract public static function getEnumsClass(): string;
}

First is the getSQLDeclaration, this will tell what kind of column the data will be stored in. Here it is text as we will store the value of the case of the Enum. To convert our Enum to a string in the convertToDatabaseValue function we first make sure that we actually have an Enum and not just a simple one but one that has values, a BackedEnum. Then all we need to do is return the value for the specific case of the Enum that is being saved.

Now when loading the data from our database we need it to convert the string into an Enum. The convertToPHPValue function takes care of that by using the BackedEnum::tryFrom method.

This class is abstract because we want to be able to get this to work with all our Enums. it defines, and uses, the getEnumsClass function.

Now we can create a class specific to our Enum FileTypeType.

<?php

namespace App\DoctrineType;

use App\Enum\FileType;

class FileTypeType extends AbstractEnumType
{
    public const NAME = 'fileType';

    public static function getEnumsClass(): string
    {
        return FileType::class;
    }

    public function getName(): string
    {
        return self::NAME;
    }
}

A doctrine Type needs a NAME constant and a getName() function. This is what we will be using in our Entity classes to tell doctrine how to map the property.

#[ORM\Entity(repositoryClass: ShotConfigurationRepository::class)]
class ShotConfiguration
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private $id;

    #[ORM\Column(type: FileTypeType::NAME, length: 255)]
    private $fileType = FileType::PNG;

    ...
}

Last thing to do is to tell Symfony to take our new Doctrine types into account. First we need to add all of our type classes as services inside the services.yaml file

    _instanceof:
      App\DoctrineType\AbstractEnumType:
        tags: [ 'app.doctrine_enum_type' ]

Anything that extends our base abstract class will be registered as a service with the app.doctrine_enum_type tag. Next we need to pass on these types to the doctrine configuration. We can do this by updating our Kernel class.

<?php

namespace App;

use App\DoctrineType\AbstractEnumType;
use JetBrains\PhpStorm\NoReturn;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;

class Kernel extends BaseKernel implements CompilerPassInterface
{
    use MicroKernelTrait;

    public function process(ContainerBuilder $container): void
    {
        $typesDefinition = [];
        if ($container->hasParameter('doctrine.dbal.connection_factory.types')) {
            $typesDefinition = $container->getParameter('doctrine.dbal.connection_factory.types');
        }

        $taggedEnums = $container->findTaggedServiceIds('app.doctrine_enum_type');

        /** @var $enumType AbstractEnumType */
        foreach ($taggedEnums as $enumType => $definition) {
            $typesDefinition[$enumType::NAME] = ['class' => $enumType];
        }
        $container->setParameter('doctrine.dbal.connection_factory.types', $typesDefinition);
    }
}

It will check all the types that have already been defined, look for all the services that we have just tagged and then, one by one, add those types to the doctrine configuration.

That's it for the Enums integration in my project. Adding them to doctrine requires some work but, I think, it is worth it. This makes the values way more strict and adds a lot of functionality that we would not have had if these values were just strings.