Skip to content

[Workflow] Add support for \BackedEnum in MethodMarkingStore #60114

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: 7.3
Choose a base branch
from

Conversation

tucksaun
Copy link
Contributor

@tucksaun tucksaun commented Apr 1, 2025

Q A
Branch? 7.3
Bug fix? no
New feature? yes
Deprecations? no
Issues Kind of relates to #44211
License MIT
Why supporting Backed Enums in Workflow can be useful

Supporting Enums in Workflow (and overall in Symfony) is a highly debated topic. While using Enums is not always a good choice, I personally found that Backed Enums are really suited for status and places because they ensure type safety in your model and naturally validate the set of allowed values. This is why I think supporting them in Workflow could be nice if not too complicated.

Reducing the supported scope to `MethodMarkingStore`

While trying to implementing BackedEnum support in my current project using custom code, I figured we could go with a really narrow scope to support them out-of-the-box (but only for Single State Machine): in such case only MethodMarkingStore has to support Enums. In my mind, the way the workflow uses strings internally in the Marking is an implementation detail and what really matters to users is being able to control the values stored in their objects. Also, I don’t believe supporting enums for transitions is necessary as they are mostly configuration and validating transitions is already part of the component’s job (and in such case I would recommend using constants anyway). Finally, supporting Enum values in the configuration is not a target at the moment either because it can already be done as one can use !php/enum if they are using yaml files or use PHP config and directly use the enums. And improving this experience can be done later on if deemed necessary.

Supporting BackedEnum currently requires some boilerplate in the user project (with a dedicated getter for instance) or a complete implementation of a marking store. Therefore I’m sharing what is in my mind a small patch that can greatly improve the situation for users as it allows them to use Backed Enums in their model right away with their getters (ideally we would not need this patch but as PHP will not allow Enums to implement \Stringable soon we need to do it ourself). The complicated part could be the setter but as PHP supports union types we can document how users can support them:

class MyObject {
    // ...
    private ObjectStatus $status = ObjectStatus::Draft;

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

    public function setStatus(ObjectStatus|string $status): static
    {
        if (\is_string($status)) {
            $status = ObjectStatus::from($status);
        }

        $this->status = $status;

        return $this;
    }
}

WDYT?

| Q             | A
| ------------- | ---
| Branch?       | 7.3
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Issues        | Kind of relates to symfony#44211
| License       | MIT

Supporting Enums in Workflow (and overall in Symfony) is a highly debated topic. While using Enums is not always a good choice, I personally found that Backed Enums are really suited for status and places because they ensure type safety in your model and naturally validate the set of allowed values. This is why I think supporting them in Workflow could be nice if not too complicated.

While trying to implementing this in my current project using custom code, I figured we could go with a really narrow scope to support them out-of-the-box (but only for Single State Machine): in such case only `MethodMarkingStore` has to support Enums. In my mind, the way the workflow uses strings internally in the `Marking` is an implementation detail and what really matters to users is being able to control the values stored in their objects.
Also, I don’t believe supporting enums for transitions is necessary as they are mostly configuration and validating transitions is already part of the component’s job (and in such case I would recommend using constants anyway).
Finally, supporting Enum values in the configuration is not a target at the moment either because it can already be done as one can use `!php/enum` if they are using yaml files or use PHP config and directly use the enums. And improving this experience can be done later on if deemed necessary.

Supporting this use case currently requires some boilerplate in the user project (with a dedicated getter for instance). Or completely reimplementing a marking store.
Therefore I’m sharing what is in my mind a small patch that can greatly improve the situation for users as it allows them to use Backed Enums in their model right away with their getters (ideally we would not need this patch but as PHP will not allow Enums to implement `\Stringable` soon we need to do it ourself).
The complicated part could be the setters but now that PHP supports union types we can only document how users can support them:
```php
class MyObject {
    // ...
    private ObjectStatus $status = ObjectStatus::Draft;

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

    public function setStatus(ObjectStatus|string $status): static
    {
        if (\is_string($status)) {
            $status = ObjectStatus::from($status);
        }

        $this->status = $status;

        return $this;
    }
}
```
@tucksaun tucksaun force-pushed the feat/workflow-backed-enum branch from 8e589e4 to 54af6fa Compare April 2, 2025 07:01
Copy link
Member

@alexandre-daubois alexandre-daubois left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am surprised the implementation is that short. But that's good thing!

This was my very first try at this feature, and it was really challenging to bring full support (I mean support enums in workflows everywhere, especially in the Framework configuration).

I also shared a backed enum marking store, I'm sharing it here just for reference 🙂 #54582 (comment)

So, if I get this right, you propose to bring reduced scope. But does it make sense to improve the marking store without bringing support to the Framework config as well? From my experience, devs mostly declare their workflows in framework.yaml (and other formats of course). And this last point was a pain to implement right. Maybe things changed since?

Co-authored-by: Alexandre Daubois <2144837+alexandre-daubois@users.noreply.github.com>
@tucksaun
Copy link
Contributor Author

tucksaun commented Apr 2, 2025

Thank you for the questions @alexandre-daubois

So, if I get this right, you propose to bring reduced scope.

Yes, because in my mind the fact that the Workflow uses strings internally is ok. If PHP was allowing to cast Backed Enums to string it would actually “just” work. So in my opinion we don’t need to deal with Enum internally, only at the component boundaries and where it makes the more sense: places.

I also shared a backed enum marking store, I'm sharing it here just for reference 🙂 #54582 (comment)

Yes! I’ve seen it and it is interesting. But I tried to find a solution that does not require my team to implement a custom service for each workflow (our project requires a tons of them and it would make onboarding new developers on the project more easy).

But does it make sense to improve the marking store without bringing support to the Framework config as well? From my experience, devs mostly declare their workflows in framework.yaml (and other formats of course). And this last point was a pain to implement right. Maybe things changed since?

Good question! I didn’t had a look initially because the project I’m working on is using YAML and I’m not sure we can do much more than what is currently possible using !php:enum:

framework:
    workflows:
        my_workflow:
        #
            places:
                - !php/enum App\Enumeration\ObjectStatus::Draft->value
                - !php/enum App\Enumeration\ObjectStatus::Received->value
         #

But I was convinced we could do something in FrameworkBundle for the PHP format so I quickly tried something this morning working with the normalization process and managed to make it work to support the following snippet:

// config/packages/workflow.php

return static function (FrameworkConfig $framework): void {
    $missionWorkflow = $framework->workflows()->workflows('mission');
    $missionWorkflow
        ->type('state_machine')
        ->supports(\App\Entity\MyObject::class)
        ->initialMarking([\App\Enumeration\ObjectStatus::Draft]);
    // …
    $missionWorkflow->transition()
        ->name('complete')
            ->from([\App\Enumeration\ObjectStatus::Draft])
            ->to([\App\Enumeration\ObjectStatus::Received])
};

and it also seems to allow removing the ->value in YAML (and it should be backward compatible):

framework:
    workflows:
        my_workflow:
        #
            places:
                - !php/enum App\Enumeration\ObjectStatus::Draft
                - !php/enum App\Enumeration\ObjectStatus::Received
         #

So if this current PR is accepted I can work on improving the support in FrameworkBundle for 7.4.

@alexandre-daubois
Copy link
Member

alexandre-daubois commented Apr 2, 2025

Good question! I didn’t had a look initially because the project I’m working on is using YAML and I’m not sure we can do much more than what is currently possible using !php:enum:

framework:
    workflows:
        my_workflow:
        #
            places:
                - !php/enum App\Enumeration\ObjectStatus::Draft->value
                - !php/enum App\Enumeration\ObjectStatus::Received->value
         #

Doesn't it defeat the purpose of enumerations? The consensus was that places are finite and precisely defined in the configuration, making them already "strongly typed" (as trying to move to an undefined place throws an exception), which makes workflow places actually enum of their kind. This was the biggest reason for not going further with this idea, thus supporting enumeration didn't bring anything actually.

But anyway I get what you mean. Although discussions around enum support have concluded in the past that it wasn't worth the effort, perhaps opinions have changed in the last few years. I'd be happy to hear other people's opinion on this, and don't get me wrong: I think I'd be glad to see this feature implemented given the number of times it's been requested by the community. 🙂 I just want to clarify the points that led to the subject being dropped the last few times, so that we can move in the right direction and eliminate show stoppers. Thank you for digging the subject!

@stof
Copy link
Member

stof commented Apr 2, 2025

using a type of BackedEnum in the Workflow component does not provide any type safety guarantee. Enums give extra type safety when your type is the specific enum you intend to support. But the workflow component allows configuring the list of places and transitions, and so cannot define an enum for the parameter type.
As far as the workflow component is concerned, the list of places and transitions is not a closed list (and so is a bad fit for an enum).

If you access the backing value each time you use your enum, you just use a more verbose syntax for string constants, with no additional type safety compared to string constants.

@OskarStark OskarStark changed the title [Workflow] Add support for Backed Enum in MethodMarkingStore [Workflow] Add support for \BackedEnum in MethodMarkingStore Apr 2, 2025
@nicolas-grekas
Copy link
Member

This makes completely sense to me.
I'm just not a fan of the union on the setter that this requires.
What about checking in the compiler pass which enum class setters use and give that to an augmented marking store so that it knows it should cast to enums before calling the setter?

@lyrixx
Copy link
Member

lyrixx commented Apr 10, 2025

I'm fine with this.

And I like @nicolas-grekas comment. BUT, this could be hard to support all cases.
We could at least use the support option configuration. If one uses support_strategy, we could not.

@nicolas-grekas
Copy link
Member

We could at least use the support option configuration. If one uses support_strategy, we could not.

Would work for me - making simple cases simple.

Another idea (draft, for another PR):
support defining places via an enum (a backed one):

places: !php/enum FOO

This would make the workflow still use strings for places, but use enums for the marking store.

@tucksaun
Copy link
Contributor Author

hi folks

Sorry for the delay in the response (busy week here)

The consensus was that places are finite and precisely defined in the configuration, making them already "strongly typed" (as trying to move to an undefined place throws an exception), which makes workflow places actually enum of their kind. This was the biggest reason for not going further with this idea, thus supporting enumeration didn't bring anything actually.

I would agree if the Workflow was the only place where one could set those values. But when using the MethodMarkingStore, it calls public methods, so nothing is preventing a call to setStatus with an inappropriate value or an external database alteration. One can do it manually but Enums can help here (preventing the use of values not allowed in the set). This is not entirely error-proof and can still let business logic errors happen or inconsistent states, but in some contexts (eg. government projects) you are asked to use every possible protection measure 🤪

Please note I don't apply this reasoning to transitions as those are only usable via the Worfklow system.

But the workflow component allows configuring the list of places and transitions, and so cannot define an enum for the parameter type.

In my view, this is the responsibility of the configuration (which is not part of this PR), and one can easily do it when using PHP for configuration:

foreach (\App\Enumeration\ObjectStatus::cases() as $case) {
    $myWorkflow->place()->name($case->value);
}

And IF someone is willing to add to the syntactic sugar later (like @nicolas-grekas idea) on or globally improve Enums support in configuration, they are free to do it.

As far as the workflow component is concerned, the list of places and transitions is not a closed list (and so is a bad fit for an enum).

Is it not the opposite of what @alexandre-daubois is saying? ("The consensus was that places are finite and precisely defined in the configuration, making them already "strongly typed"")

If you access the backing value each time you use your enum, you just use a more verbose syntax for string constants, with no additional type safety compared to string constants.

Maybe I'm not seeing what you refer to: I don't think I'm constantly accessing the backing value here. Am I missing something?

I'm just not a fan of the union on the setter that this requires.

Yes indeed, but this looked like a good middle ground to me as it does not involve too much implementation on Symfony's side. Plus, it makes it kind of BC and provides an upgrade path.

What about checking in the compiler pass which enum class setters use and give that to an augmented marking store so that it knows it should cast to enums before calling the setter?

As @lyrixx said, it seemed a bit complicated to do "quickly" and in a performant way.
But using supports and a compiler pass might make it easier indeed.
However, this will probably require some refactoring as the MethodMarkingStore is quite closed for now.
I'm okay to work on such a feature if we are willing to go this way (I already have something locally that I tried using reflection to know which Enum type to instantiate)

@alexandre-daubois
Copy link
Member

alexandre-daubois commented Apr 10, 2025

Perhaps I misspoke, so to clarify: when I said "finite values", I meant that if we use workflow transitions, we're constrained to the values of a defined set. But actually, values can be non-finite in the sense that any string can be used to define a workflow place. Maybe this explains the feeling of "two opposite ideas" we seemed to give

@lyrixx
Copy link
Member

lyrixx commented Apr 12, 2025

Another idea (draft, for another PR): support defining places via an enum (a backed one):

places: !php/enum FOO

Actually, this is super easy :) I'll open a PR soon (except for XML 🥺)

edit: here we go #60204

@lyrixx
Copy link
Member

lyrixx commented Apr 12, 2025

@tucksaun I think you can leverage #60204 to configure the marking store. Now we can be 100% sure the places are BackedEnum.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants