How to map a PHP enum with Doctrine in a Symfony project ?

Published on

Mar 18, 2022

PHP 8.1 was released on November 25, 2021 and comes with many awesome new features including enums 😍 ! There is already a lot of nice articles presenting how they can be used so we won't get into details about the core features. But there is still an interesting topic that we like to share with you: How to map theses enums with Doctrine in a Symfony project ?

First thing first, let's say you have the following Article Entity:

// src/App/Entity/Article.php

<?php

declare(strict_types=1);

namespace App\Entity;

use Symfony\Component\Uid\Uuid;
use Symfony\Component\Uid\UuidV4;

class Article
{
    private UuidV4 $id;
    private string $title;
    private string $content;
    private ArticleStatus $status;

    public function __construct(
        string $title,
        string $content,
        ArticleStatus $status
    ) {
        $this->id = Uuid::v4();
        $this->title = $title;
        $this->content = $content;
        $this->status = $status;
    }   

    public function getStatus(): ArticleStatus
    {
        return $this->status;
    }

    // Needed getters, setters and domain methods
}

And the ArticleStatus enum:

// src/App/Entity/ArticleStatus.php

<?php

declare(strict_types=1);

namespace App\Entity;

enum ArticleStatus: string 
{
    case DRAFT = 'draft'; 
    case PUBLISHED = 'published'; 
    case ARCHIVED = 'archived'; 
}

Now we want this entity to be mapped to our database. This is usually done with Doctrine in a Symfony project. As the enum type in PHP is not a proper string we cannot map it as a standard string. Unfortunately there is no builtin doctrine enum type yet so we have to create a custom Type in order to map this field to our database.

Disclaimer: In the following example we will present an implementation based on mysql only to keep things simple. There will be a complete example with other database platform at the end of the article.

The first thing we have to do is to create an abstract doctrine Type to represent a generic enum:

// src/App/Doctrine/DBAL/Type/EnumType.php

<?php

declare(strict_types=1);

namespace App\Doctrine\DBAL\Type;

use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\Type;

abstract class EnumType extends Type
{
    /**
     * @return class-string 
     */
    protected abstract function getEnum(): string;

    public function getSQLDeclaration(array $column, AbstractPlatform $platform) 
    { 
        $enum = $this->getEnum();
        $cases = array_map(
            fn ($enumItem) => "'{$enumItem->value}'", 
            $enum::cases()
        );

        return sprintf('ENUM(%s)', implode(', ', $cases));
    }

    public function requiresSQLCommentHint(AbstractPlatform $platform)
    {
        return true;
    }
    
    public function convertToPHPValue($value, AbstractPlatform $platform)
    {
        $enumClass = $this->getEnum();
        
        return $enumClass::from($value);
    }

    public function convertToDatabaseValue($enum, AbstractPlatform $platform)
    {
        return $enum->value;
    }
}

There are a few things that need to be explained in this class:

  • The abstract getEnum method will have to be implemented and must return the FQCN of the according enum. (i.e: return ArticleStatus::class).
  • The getSQLDeclaration method will be used by doctrine to generate the field signature during migrations / database updates and is specific to the database platform that you use (here is an example for mysql). The enum::cases() method is built-in in php and returns instances of enum values and therefore are not directly castable in strings (or whatever type the enum values are). To get these raw values, enum have a public property called value. This is the job of the following line in the method:
$cases = array_map(
    fn ($enumItem) => "'{$enumItem->value}'", 
    $enum::cases()
);

💡 Notice the value needs to be wrapped with single quotes "'{$enumItem->value}'" to prevent any reserved keywords conflict in mysql.

  • The requiresSQLCommentHint method is used to put a special comment on the column declaration and helps doctrine to make sure that the field mapping is up-to-date. If you don't override this method to return true, doctrine will constantly try to update the column definition each time you generate a migration for no reason.
  • The convertToPHPValue and convertToDatabaseValue methods are self explanatory and are the bridges between PHP and database values. In the convertToPHPValue we receive a string from the database and we convert this value to a proper enum. In the other hand the convertToDatabaseValue receives an enum and we return the scalar value of the enum (as we did in the previous array_map 😎).

Now we have to create the implementation of our abstract EnumType which will be specific to the ArticleStatus enum:

// src/App/Doctrine/DBAL/Type/ArticleStatusEnumType.php

<?php

declare(strict_types=1);

namespace App\Doctrine\DBAL\Type;

use App\Entity\ArticleStatus;

class ArticleStatusEnumType extends EnumType
{
    protected function getEnum(): string 
    { 
        return ArticleStatus::class;
    }

    public function getName() 
    { 
        return 'article_status_enum';
    }
}

⚠️ We still need to declare this new custom type in the doctrine configuration file (assuming you are using a standard Symfony architecture):

doctrine:
    dbal:
        driver: pdo_mysql
        # ....
        
        types:
            award_type_enum: App\Doctrine\DBAL\Type\ArticleStatusEnumType
            
        mapping_types:
            enum: string

As you can see creating specific doctrine type is quite straightforward since the logic is handled in the generic EnumType.  

Last but not least, we have to write the mapping of the Entity. Here is an example using annotations:

<?php

declare(strict_types=1);

namespace Domain\Model;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Uid\UuidV4;

/**
 * @ORM\Entity()
 */
class Article
{
    /**
     * @ORM\Id()
     * @ORM\Column(type="uuid")
     */
    private UuidV4 $id;

    /**
     * @ORM\Column(type="string")
     */
    private string $title;

    /**
     * @ORM\Column(type="string")
     */
    private string $content;

    /**
     * @ORM\Column(type="article_status_enum")
     */
    private ArticleStatus $status;

    // ...
}

To go further here is an example to deal with several database platform:

// src/App/Doctrine/DBAL/Type/EnumType.php

<?php

declare(strict_types=1);

namespace Infrastructure\Doctrine\DBAL\Type;

use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Platforms\SqlitePlatform;
use Doctrine\DBAL\Types\Type;

abstract class EnumType extends Type
{
    // ...

    public function getSQLDeclaration(array $column, AbstractPlatform $platform) 
    { 
        $enum = $this->getEnum();
        $cases = array_map(
            fn ($enumItem) => "'{$enumItem->value}'", 
            $enum::cases()
        );

        if ($platform instanceof MySQLPlatform) {
            return sprintf('ENUM(%s)', implode(', ', $cases));
        }

        if ($platform instanceof SqlitePlatform) {
            // return specific declaration for SQLlite
        }
    }

    // ...
}

⚠️ Doctrine migrations: With this approach, doctrine won't automatically notice any changes in your PHP enum. Let's say you want to add a new ready-to-review status in the enum:

// src/App/Entity/ArticleStatus.php

<?php

declare(strict_types=1);

namespace App\Entity;

enum ArticleStatus: string 
{
    case DRAFT = 'draft'; 
    case PUBLISHED = 'published'; 
    case ARCHIVED = 'archived'; 
    case READY_TO_REVIEW = 'ready-to-review';
}

You have to write the migration manually so your schema can be up-to-date with the enum cases.

💡Nevertheless you can still have an automated migrations generation by using the following doctrine event listener using the columns comments to save the hash of the actual enum cases:

// src/Doctrine/EventListener/EnumTypeListener.php

<?php

declare(strict_types=1);

namespace App\Doctrine\EventListener;

use App\Doctrine\DBAL\Type\EnumType;

class EnumTypeListener 
{
    public function postGenerateSchema(\Doctrine\ORM\Tools\Event\GenerateSchemaEventArgs $eventArgs)
    {
        $columns = [];

        foreach ($eventArgs->getSchema()->getTables() as $table) {
            foreach ($table->getColumns() as $column) {
                if ($column->getType() instanceof EnumType) {
                    $columns[] = $column;
                }
            }
        }

        /** @var \Doctrine\DBAL\Schema\Column $column */
        foreach ($columns as $column) {
            /** @var EnumType $type */
            $type = $column->getType();
            $enum = $type->getEnum();

            $cases = array_map(
                fn ($enumItem) => "'{$enumItem->value}'", 
                $enum::cases()
            );

            $hash = md5(implode(',', $cases));

            $column->setComment(trim(sprintf(
                '%s (DC2Enum:%s)', 
                $column->getComment(), 
                $hash 
            )));
        }
    }
}

And register this listener in the Symfony services:

# config/services.yaml

parameters:

services:
    # ...
    
    Infrastructure\Doctrine\EventListener\EnumTypeListener:
        tags:
            - { name: doctrine.event_listener, event: postGenerateSchema }

Now the result of the command bin/console doctrine:migration:diff should generate a new column definition anytime you update the php enum. The migration should look like:

// migrations/Version20220314144148.php

<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
 * Auto-generated Migration: Please modify to your needs!
 */
final class Version20220314144148 extends AbstractMigration
{
    public function getDescription(): string
    {
        return '';
    }

    public function up(Schema $schema): void
    {
        // this up() migration is auto-generated, please modify it to your needs
        $this->addSql('ALTER TABLE article CHANGE status status ENUM(\'draft\', \'published\', \'archived\', \'ready-to-review\') NOT NULL COMMENT \'(DC2Enum:e498ac00fb4b018cfc2fd965b12ebb0e)(DC2Type:article_status_enum)\'');
    }

    public function down(Schema $schema): void
    {
        // this down() migration is auto-generated, please modify it to your needs
        $this->addSql('ALTER TABLE article CHANGE status status ENUM(\'draft\', \'published\', \'archived\', \'ready-to-review\') NOT NULL COMMENT \'(DC2Enum:6195e77175490390059d0bf706a964bb)(DC2Type:article_status_enum)\'');
    }
}

⚠️ This listener allows to generate a proper up migration BUT as you may have noticed: The down migration keeps using the value ready-to-review when it should have been removed in order to be sync with the previous enum state. If you decide to go with this listener you have to be aware that you still need to manually update the down migration in order to have a fully clean migration. ⚠️

Hope this article helps you guys! Feel free to share this article or ping us on Twitter if you have any question!

EDIT: There is already an builtin doctrine (v2.11+) support of enum using the following parameter:

/**
 * @ORM\Column(type="string", enumType: "App\Enum\ArticleStatus")
 */
private $status;

You can check the full documentation here: https://www.doctrine-project.org/2022/01/11/orm-2.11.html

Built-in

This built-in approach will produce a database column with string type and the value will be validated on the PHP side when it is stored/retrieved from the database.

This article approach

The main difference with this article approach is that we aim to produce a real enum([VALUES], ...) type in our database. This ensure that any values that exists in the database is valid.

Why is it so important ? Let’s say you have a enum with values draft, published , archived , outdated and you started to insert some values in your table. You have to drop the outdated value for some reason. You now have outdated values remaining in your database and nothing will force you to deal with it in a dedicated migration. The probability is high that someday you will miss the migration and push the code on production with such inconsistency remaining in the database 💥.

Written by

Antoine Lelaisant
Antoine Lelaisant

Florian Quaghebeur
Florian Quaghebeur

Comments