PHP 8.1 a ajouté Enums, ce qui nous permet de définir un nombre défini de valeurs possibles pour un tableau. C'est un moyen très intéressant de s'assurer que seules les valeurs que nous voulons peuvent être à une valeur spécifique.

J'ai donc commencé à jouer avec eux il y a quelque temps et j'ai rencontré quelques problèmes avec Doctrine et le Symfony FormType.

Plongeons-y !

Enums

J'ai écrit un petit programme pour générer des captures d'écran du navigateur lorsqu'on lui donne une certaine configuration. Il utilise browsershot. Je voulais une page web avec un formulaire pour toutes les différentes options de configuration, l'une d'entre elles étant le type de sortie.

J'ai donc créé un Enum FileType :

<?php

namespace App\Enum;

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

Il y a 3 cas qui ont une valeur, c'est ce qu'on appelle une Enum. Vous pouvez accéder à ces valeurs sur une Enum en appelant simplement la propriété value.

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

Avec cela, je voulais ajouter quelques fonctions simples pour vérifier quelle sera l'extension du fichier lorsqu'un certain type est utilisé et pour vérifier si la valeur sélectionnée est une 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',
        };
    }
}

Je peux maintenant les utiliser facilement dans mon contrôleur Symfony pour définir le nom de fichier lors du téléchargement du fichier.

#[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;
    ...
}

Et dans mes modèles Twig.

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

Il est agréable de pouvoir accéder facilement à ces fonctions sur tout ce qui utilise cette Enum comme propriété. La prochaine chose dont j'avais besoin était un moyen facile d'obtenir toutes les valeurs et les cas d'une Enum pour les messages d'erreur, j'ai donc ajouté quelques fonctions supplémentaires.

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

Maintenant, dans ma commande Symfony, je peux ajouter une erreur si le type de fichier qui est défini ne fait pas partie des types disponibles.

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

Ou les cas si c'est ce que la commande attend.

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

Avec cela, j'ai maintenant une belle commande pour générer les captures d'écran, qui n'accepte que certains paramètres, et qui renvoie des erreurs utiles. Je voulais maintenant travailler sur un formulaire qui a une liste de choix pour les Enums.

EnumType

Lorsque vous générez un formulaire avec Symfony, il offre de nombreux FormTypes. L'un d'entre eux est le EnumType qui fait partie des Choice Fields. Grâce à cela, il est facile d'ajouter une liste des cas disponibles à un formulaire.

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

Maintenant, dans mon formulaire, j'aurai une liste de toutes les cases de mon Enum que l'utilisateur pourra sélectionner. C'est déjà très bien, mais je ne voulais pas seulement montrer les cas qui sont tous en majuscules et qui, pour moi, ne sont pas là pour l'utilisateur final, mais pour le côté code.

Je voulais donc trouver un moyen de montrer les valeurs et non les cas à l'utilisateur final. Cela m'a demandé un peu de recherche. J'ai fini par plonger dans le fichier Symfony EnumType situé à vendor/symfony/form/Extension/Core/Type/EnumType.php et j'ai vu ce qui suit :

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

Par défaut, le label_choix, les valeurs de la liste déroulante, sont définies par le nom de l'énumération. Cela est logique, car une Enum n'est pas obligée d'avoir une valeur. Maintenant, je peux remplacer cela dans mon FormType pour rendre la valeur à la place.

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

Le formulaire est maintenant prêt. Ensuite, nous devons être capables de sauvegarder et de récupérer l'Enum dans notre base de données.

Doctrine

Lorsque nous utilisons Symfony, nous sommes habitués à ce que Doctrine sauvegarde et récupère nos entités. Il se charge de transformer notre entité en quelque chose que la base de données peut gérer et lorsque nous interrogeons quelque chose à partir de celle-ci, elle renvoie l'entité avec tous les types corrects sur ses propriétés.

Lorsqu'une entité a pour propriété un DateTime, Doctrine la stocke comme telle dans la base de données du projet. Une Enum n'est pas quelque chose que Doctrine peut comprendre tout seul, nous devons lui donner un peu d'aide. J'ai fini par trouver un article qui explique comment utiliser nos Enums comme un type Doctrine.

Dans la base de données, l'Enum n'est pas une classe, mais simplement une chaîne de caractères. Nous devons lui dire comment convertir notre classe PHP en une valeur que la base de données peut comprendre et vice-versa. Pour cela, nous pouvons créer un nouveau Type qui extend 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;
}

La première est la getSQLDeclaration, qui indique dans quel type de colonne les données seront stockées. Ici, c'est text car nous allons stocker la valeur de la case de l'Enum. Pour convertir notre Enum en une chaîne de caractères dans la fonction convertToDatabaseValue. Nous devons d'abord nous assurer que nous avons bien une Enum et pas seulement une simple Enum mais une Enum qui a des valeurs, une BackedEnum. Ensuite, ce que nous devons faire est de retourner la valeur pour le cas spécifique de l'Enum qui est sauvegardé.

Maintenant, lorsque nous chargeons les données de notre base de données, nous avons besoin de convertir la chaîne en un Enum. La fonction convertToPHPValue s'en charge en utilisant la méthode BackedEnum::tryFrom.

Cette classe est abstraite, car nous voulons pouvoir la faire fonctionner avec tous nos Enums. Elle définit et utilise la fonction getEnumsClass.

Maintenant, nous pouvons créer une classe spécifique à notre 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;
    }
}

Un type de doctrine a besoin d'une constante NAME et d'une fonction getName(). C'est ce que nous utiliserons dans nos classes Entity pour indiquer à doctrine comment mapper la propriété.

#[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;

    ...
}

La dernière chose à faire est d'indiquer à Symfony de prendre en compte nos nouveaux types Doctrine. Tout d'abord, nous devons ajouter toutes nos classes de type comme services dans le fichier services.yaml.

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

Tout ce qui étend notre classe abstraite de base sera enregistré comme service avec la balise app.doctrine_enum_type. Ensuite, nous devons transmettre ces types à la configuration de doctrine. Nous pouvons le faire en mettant à jour notre classe Kernel.

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

Il va vérifier tous les types qui ont déjà été définis, rechercher tous les services que nous venons de tag et ensuite, un par un, ajouter ces types à la configuration de la doctrine.

C'est tout pour l'intégration des Enums dans mon projet. Les ajouter à la doctrine demande un peu de travail, mais je pense que cela en vaut la peine. ça rend les valeurs beaucoup plus strictes et ajoute des fonctionnalités que nous n'aurions pas eu si ces valeurs étaient simplement des Strings.