Using PHP enumerations with your Symfony workflows

Published on 2023-04-08 • Modified on 2023-04-08

This post shows how to use PHP enumerations with your Symfony workflows. Let's take advantage of this new PHP feature to clean up and improve all the workflow-related code. Let's go! 😎

» Published in "A Week of Symfony #850" (10-16 April 2023).

Prerequisite

I assume you have at least a basic knowledge of Symfony and know how to set up a workflow.

Configuration

  • PHP 8.3
  • Symfony 6.4.6

Introduction

Last week I was at the Symfony Live Paris 2023 with my fellow Les-Tilleuls.coop workmates. There was an attractive talk/return of experience about the workflow component by Florence Cauchy (πŸ‡«πŸ‡·) of the SΓ©zane company. At the end of the talk, one question was, "Did you use PHP enumerations?" and the answer was no. I was a bit surprised as the project uses PHP 8.1; it's a perfect use case for PHP enumerations. So, let's see how to do this.

Goal

The goal is to use a PHP enumeration instead of strings (and constants) for the Symfony workflow's places and take advantage of the new Doctrine enumeration option.

Workflow places are strings.

PHP enumerations are quite recent as they are only available as of PHP 8.1. It's a new great feature that makes the language better. The workflow component is not new and was introduced in Symfony 3.2 in 2016 by Fabpot and Lyrixx; logically, strings are used for places. If we look at the GitHub repository, a PR (closed now) tries to use enumerations instead of strings in the component. But, as the component is designed to use strings, it's more challenging and isn't worth the effort. So let's see how to solve this with a simple solution.

In a previous blog post, I used the fresh/doctrine-enum-bundle to simulate enumerations. Here is the class I used:

<?php

declare(strict_types=1);

namespace App\DBAL\Types;

use Fresh\DoctrineEnumBundle\DBAL\Types\AbstractEnumType;

/**
 * @extends AbstractEnumType<string,string>
 */
final class UserStateType extends AbstractEnumType
{
    public const STATE_CREATED = 'created';
    public const STATE_VALIDATED = 'validated';

    public const TYPES = [
        self::STATE_CREATED,
        self::STATE_VALIDATED,
    ];

    /**
     * @var array<string,string>
     */
    protected static array $choices = [
        self::STATE_CREATED => 'user_state_created',
        self::STATE_VALIDATED => 'user_state_validated',
    ];
}

And the doctrine configuration was:

    #[ORM\Column(name: 'state', type: 'UserStateType', nullable: false)]
    #[EnumType(entity: UserStateType::class)]
    private string $state = UserStateType::STATE_CREATED;

This time, let's add a new field for the same property, using a PHP enumeration.

The new Doctrine setup

Let's introduce the new property in the user entity. We will suffix it with AsEnum to ensure everything is evident with the old field.

    #[ORM\Column(type: 'string', nullable: false, enumType: UserState::class)]
    protected UserState $stateAsEnum = UserState::CREATED;

This time we use a PHP enumeration for the property type, but we also use the Doctrine enum option. As you can see, we have to specify the enumeration class to use, thanks to the enumType parameter.

Here is the enumeration class:

<?php

declare(strict_types=1);

namespace App\Enum;

enum UserState: string
{
    case CREATED = 'created';
    case VALIDATED = 'validated';

    public function isCreated(): bool
    {
        return $this == self::CREATED;
    }

    public function isValidated(): bool
    {
        return $this == self::VALIDATED;
    }
}

We must add the getters/setters for the new property:

    public function getStateAsEnum(): UserState
    {
        return $this->stateAsEnum;
    }

    public function setStateAsEnum(UserState $stateAsEnum): User
    {
        $this->stateAsEnum = $stateAsEnum;

        return $this;
    }

Let's reload the fixtures. Now, we have both fields. As you can see, the new field type is a varchar(255), not an actual database enumeration.

Column Type Comment
state enum('created','validated') (DC2Type:UserStateType)
state_as_enum varchar(255)

The new workflow setup

Now, let's duplicate the workflow to keep the old one:

# https://symfony.com/doc/current/workflow.html#configuration
# php bin/console debug:autowiring workflow
framework:
    workflows:
        user:
            type: 'state_machine'
            audit_trail:
                enabled: true
            marking_store:
                type: 'method'
                property: 'state'
            supports:
                - App\Entity\User
            initial_marking: created 
            places:
                - created
                - validated
            transitions:
                validate:
                    from: created
                    to:   validated

        userStateAsEnum:
            type: 'state_machine'
            audit_trail:
                enabled: true
            marking_store:
                type: 'method'
                property: 'stateAsString'
            supports:
                - App\Entity\User
            initial_marking: created
            places:
                - created
                - validated
            transitions:
                validate:
                    from: created
                    to:   validated

There is only one difference, the property value in the marking_store section is now stateEnumString instead of "state". It means the workflow will search for the related getter and setter (because we used the type: 'method' setting) for this property. These functions also handle the stateAsEnum property and must perform a type conversion in both directions (enum to string and string to an enum). Let's add these functions:

    public function getStateAsString(): string
    {
        return $this->stateAsEnum->value;
    }

    public function setStateAsString(string $stateAsString): User
    {
        $this->stateAsEnum = UserState::from($stateAsString);

        return $this;
    }

That's it! These functions handle the string-to-enum conversion and the reverse conversion. You can see the advantages now; we can't pass a wrong value to the setter, or an exception is raised. Let's check with a unit test:

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Entity;

use App\Entity\User;
use App\Enum\UserState;
use PHPUnit\Framework\TestCase;

final class UserTest extends TestCase
{
    public function testSetStateAsString(): void
    {
        $user = new User();

        // nomical cases
        $user->setStateAsString(UserState::CREATED->value);
        self::assertTrue($user->getStateAsEnum()->isCreated());
        $user->setStateAsString(UserState::VALIDATED->value);
        self::assertTrue($user->getStateAsEnum()->isValidated());

        // incorrect value
        $this->expectException(\ValueError::class);
        $this->expectExceptionMessage('"foobar" is not a valid backing value');
        $user->setStateAsString('foobar');
    }

    public function testSetState(): void
    {
        $user = new User();
        $user->setState('foobar');
        self::assertSame('foobar', $user->getState());

        // This is wrong as the user is now in an unknown state.
        // And testing the value in the setter would be ugly.
    }
}

We are now sure we don't set an incorrect value. OK, we used an enumeration for the state values, but what about the transitions? We can also use an enumeration. Let's create the class:

<?php

declare(strict_types=1);

namespace App\Enum;

enum UserStateTransition: string
{
    case VALIDATE = 'validate';

    /**
     * Can be provided by a trait.
     *
     * @return array<string,string>
     */
    public static function getAsArray(): array
    {
        return array_reduce(
            self::cases(),
            static fn (array $choices, self $type) => $choices + [$type->name => $type->value],
            [],
        );
    }
}

We only have one transition for now; the validate one. Now, let's create a simple service to use it:

<?php

declare(strict_types=1);

namespace App\Workflow;

use App\Entity\User;
use App\Enum\UserStateTransition;
use Symfony\Component\Workflow\WorkflowInterface;

final class UserStateWorkflow
{
    private WorkflowInterface $userStateAsEnumStateMachine;

    public function __construct(WorkflowInterface $userStateAsEnumStateMachine)
    {
        $this->userStateAsEnumStateMachine = $userStateAsEnumStateMachine;
    }

    public function canValidate(User $user): bool
    {
        return $this->userStateAsEnumStateMachine->can($user, UserStateTransition::VALIDATE->value);
    }

    public function validate(User $user): void
    {
        if (!$this->canValidate($user)) {
            throw new \LogicException(sprintf("Can't apply the '%s' transition on user nΒ°%s, current state: '%s'.", UserStateTransition::VALIDATE->value, $user->getId(), $user->getStateAsString()));
        }

        $this->userStateAsEnumStateMachine->apply($user, UserStateTransition::VALIDATE->value);
    }
}

Here, we use the transition enumeration to create a shortcut to validate a user. And we can add methods testing the current state of the user, and because we used an enumeration, if we don't implement all cases, we have the following warning by the IDE. We wouldn't have this kind of warning when using string constants:


a PHPStorm warning

Of course, we also take advantage of the enumeration-specific static analysis.

Twig

Some notes about Twig. We can use and test the places like this:

{{ user.stateAsEum.isValidated }}

We use the shortcuts we defined in the enumeration class; it is nice because it doesn't spoil the main entity and works for simple cases. For more advanced cases, you may need a Twig extension. Regarding the workflow Twig helpers like {{ workflow_can(). Instead of using raw strings like we used to, we can do the following:

{{ workflow_can(user, user_state_transition.VALIDATE) }}

Before, we must create a simple extension that injects the enumeration cases in the global Twig context:

<?php

declare(strict_types=1);

namespace App\Twig\Extension;

use App\Enum\UserStateTransition;
use Twig\Extension\AbstractExtension;
use Twig\Extension\GlobalsInterface;

final class UserExtension extends AbstractExtension implements GlobalsInterface
{
    public function getGlobals(): array
    {
        return [
            'user_state_transition' => UserStateTransition::getAsArray(),
        ];
    }
}

We don't have hardcoded values in the templates anymore. We will have an error when the Twig template is parsed, and the case is renamed. The error also shows the list of allowed values.

{{ user_state_transition.FOOBAR }}

Key "FOOBAR" for array with keys "VALIDATE" does not exist.

If only the case value is renamed, the new value is automatically used, and nothing has to be modified or does not exist. The code is cleaner, and if you rename your transitions, it will be easier to do. As indicated in the comments, the getAsArray() function of the enumeration can be extracted to a trait, and reused in other classes.

More on workflows and enumerations on this blog

Conclusion

We saw how to use PHP enumerations for places and transitions of workflows. It's straightforward to implement, and we take advantage of this very nice PHP 8.1 feature. Using enumerations makes it impossible to set wrong values in the marking, a significant improvement against strings. The example uses a state machine; I didn't try to use this with a workflow allowing a marking to be in multiple places; that should be doable. We would use an array of enumerations in this case. The getter and setter would have to be modified accordingly and handle the conversion as we did in the setStateAsString() function. This simple example uses two states and one transition, but the more states and transitions you have, the more the benefits will be obvious.

That's it! I hope you like it. Check out the links below to have additional information related to the post. As always, feedback, likes and retweets are welcome. (see the box below) See you! COil. 😊

  Read the doc

  Work with me!


Call to action

Did you like this post? You can help me back in several ways: (use the Tweet on the right to comment or to contact me )

Thank you for reading! And see you soon on Strangebuzz! πŸ˜‰

COil