Search code examples
phpsymfonyormdoctrine

How to use actual ENUM type in Symfony Doctrine?


ENUM types are awesome. They allow strict value restrictions and make code refactoring easy. Unfortunately, PHP not only lacks these until version 8.1, the Doctrine DBAL also lacks behind and does not offer a easy to use solution out of the box. I was looking for a solution that would allow me:

  • native ENUM type in DB
  • no magic strings in PHP
  • as little code repetition as possible
  • PHP 7.4+ (cannot use PHP 8.1)

This question is to be self-answered for those looking for such solution, because after hours of struggle, I am quite proud of what I made. See below, hope it helps:


Solution

  • Start by creating an abstract base class which extends Doctrine\DBAL\Types\Type. This allows it to be used as a type in Entity column declarations.

    <?php
    
    namespace App\DBAL;
    
    use Doctrine\DBAL\Types\Type;
    use Doctrine\DBAL\Platforms\AbstractPlatform;
    use Exception;
    use InvalidArgumentException;
    use ReflectionClass;
    
    abstract class EnumType extends Type
    {
        
        private static ?array $constCacheArray = NULL;
        
        public static function getConstants() : array
        {
            if (self::$constCacheArray == NULL)
                self::$constCacheArray = [];
            
            $calledClass = get_called_class();
            
            if (!array_key_exists($calledClass, self::$constCacheArray)) {
                $reflect = new ReflectionClass($calledClass);
                self::$constCacheArray[$calledClass] = $reflect->getConstants();
            }
            
            return self::$constCacheArray[$calledClass];
        }
        
        public static function isValidName($name, $strict = false) : bool
        {
            $constants = self::getConstants();
            
            if ($strict) {
                return array_key_exists($name, $constants);
            }
            
            $keys = array_map('strtolower', array_keys($constants));
            return in_array(strtolower($name), $keys);
        }
        
        public static function isValidValue($value, $strict = true) : bool
        {
            $values = array_values(self::getConstants());
            return in_array($value, $values, $strict);
        }
        
        protected static string $name;
        
        public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
        {
            $values = array_map(function ($val) {
                return "'" . $val . "'";
            }, self::getConstants());
            
            return "ENUM(" . implode(", ", $values) . ")";
        }
        
        /**
         * @param $value
         * @param AbstractPlatform $platform
         * @return mixed
         */
        public function convertToPHPValue($value, AbstractPlatform $platform)
        {
            return $value;
        }
        
        /**
         * @param $value
         * @param AbstractPlatform $platform
         * @return mixed
         */
        public function convertToDatabaseValue($value, AbstractPlatform $platform)
        {
            $this->checkValue($value);
            
            return $value;
        }
        
        /**
         * @param $value
         * @throws InvalidArgumentException
         */
        public function checkValue($value): void
        {
            if (!self::isValidValue($value)) {
                throw new InvalidArgumentException("Invalid '" . static::$name . "' value.");
            }
        }
        
        public function getName(): string
        {
            return static::$name;
        }
        
        public function requiresSQLCommentHint(AbstractPlatform $platform): bool
        {
            return true;
        }
        
        public static function getValuesArray(): array
        {
            return self::getConstants();
        }
        
        /**
         * @throws Exception
         */
        public static function getChoicesArray(): array
        {
            throw new Exception("Not implemented");
        }
    }
    

    Credit for the base of this goes to @Brian Cline

    Whats important is that this class provides some helper functions with Reflection, but it also has inherited functions that allow it to be used as actual DB type. I will show you the usage with an example below.

    This is how you define a new ENUM type:

    <?php
    
    namespace App\DBAL;
    
    class AdminRoleType extends EnumType
    {
        public const ADMIN = 'ROLE_ADMIN';
        public const SUPER_ADMIN = 'ROLE_SUPER_ADMIN';
        public const CSR = 'ROLE_CSR';
        public const MANAGER = 'ROLE_MANAGER';
        public const ACCOUNTING = 'ROLE_ACCOUNTING';
        
        protected static string $name = 'admin_role';
    }
    

    Pretty simple, right? This out of the box allows you to some cool things in PHP such as:

    $myRole = AdminRoleType::CSR; // 'ROLE_CSR'
    $isValidRole = AdminRoleType::isValidValue('ROLE_ADMIN'); // true
    $isValidRole = AdminRoleType::isValidName('ADMIN'); // true
    

    But still we did not achieve actual ENUM type in our DB table. To do this, first add the following to your config/packages/doctrine.yaml:

    doctrine:
        dbal:
            mapping_types:
                enum: string
            types:
                admin_role: App\DBAL\AdminRoleType
    

    This maps DB ENUM type to local string type (sorry, native ENUMs are not in this solution, but for PHP 8.1 could(?) be possible.)

    The last step is your Entity class:

        /**
         * @ORM\Column(name="admin_role", type="admin_role")
         */
        private string $admin_role = AdminRoleType::CSR;
    
    
        public function getAdminRole(): string
        {
            return $this->admin_role;
        }
        
        /**
         * @param string $admin_role
         * @return $this
         * @throws InvalidArgumentException
         */
        public function setAdminRole(string $admin_role): self
        {
            if(!AdminRoleType::isValidValue($admin_role))
                throw new InvalidArgumentException('Invalid Admin Role');
            
            $this->admin_role = $admin_role;
        
            return $this;
        }
    

    As you can see the code will throw an exception if you try to set some string that is not allowed value for your ENUM.

    And when you do migration, the output should look like:

    ALTER TABLE admin CHANGE admin_role admin_role ENUM('ROLE_ADMIN', 'ROLE_SUPER_ADMIN', 'ROLE_CSR', 'ROLE_MANAGER', 'ROLE_ACCOUNTING') NOT NULL COMMENT '(DC2Type:admin_role)'
    

    That's it. When you work in PHP, remember to use AdminRoleType:: class instead of magic strings. If you need to add/remove item in enum, just add/remove public const from the enum class.