Skip to content

[Workflow] Declare Workflow with PHP Class and attributes #58503

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
thomassebert opened this issue Oct 8, 2024 · 17 comments
Open

[Workflow] Declare Workflow with PHP Class and attributes #58503

thomassebert opened this issue Oct 8, 2024 · 17 comments
Labels

Comments

@thomassebert
Copy link

Description

Hello,

I really like the Workflow component, but not his yaml configuration. Constants in yaml are too verbose, as is passing config to php format. So I tried to create three attributes: [AsWorkflow], to be set on a class (which should extend AbstractWorkflow), [Place], to be set on a constant, and [Transition], also to be set on a constant. With these three attributes, and a few configurations that I've put in a dedicated bundle for now, it's possible to add to Symfony's Workflow component the ability to simply declare a Workflow via a PHP class, autowire it directly from that class (you can use can(), apply(), .. functions directly from the class) and, if need be, mix it with the classic yaml configuration without any hassle or BC Break.

In the bundle, metadata management, testing and documentation still need to be completed, but the code works well, it's being used in real projects and everything's running smoothly. Before going any further, I'd like to hear your opinion on whether this could be integrated directly into the Workflow component? Is this the right approach? I could do a PR if I get positive feedback.

The code is available here => https://packagist.org/packages/letots/workflow-extension-bundle

To test, simply run the compose require letots/workflow-extension-bundle command in a recent Symfony project (attribute support is required) and create a new class (see example below). You can then debug the workflow, use it in any other class with autowire (you can still use the WorkflowInterface with Target attribute but you can also inject directly your class), ...

Is it really useful? Apart from the syntax, which I personally find clearer in PHP, we have the advantage of not having to configure anything for this to work, of having a separate PHP file for each Workflow and therefore of having autowiring with autocompletion of the class name, the advantages of using constants for autocompletion and validation to define the names of Places and Transitions (no need to go back 10 times in the workflow.yaml file to find out how a particular status was named), ... Last argument, even if this only applies to a few very specific cases, it is possible to create a dynamic workflow by rewriting the getPlaces() and getTransitions() functions of the AbstractWorkflow file, in order to add Places dynamically, for example, depending on the properties of the target object, all grouped together in the dedicated workflow class.

Example

<?php

// src/Workflow/InterventionWorkflow.php (but can be placed anywhere, just need AsWorkflow attribute and extend AbstractWorkflow

namespace App\Workflow;

use App\Entity\Intervention;
use LeTots\WorkflowExtension\AbstractWorkflow;
use LeTots\WorkflowExtension\Attribute\AsWorkflow;
use LeTots\WorkflowExtension\Attribute\Place;
use LeTots\WorkflowExtension\Attribute\Transition;

#[AsWorkflow(name: 'intervention_status', type: AsWorkflow::TYPE_STATE_MACHINE, supportStrategy: Intervention::class)]
class InterventionWorkflow extends AbstractWorkflow
{
	// Places
	#[Place(initial: true)]
	public const string STATE_QUALIFY = 'state_qualify';

	#[Place]
	public const string STATE_EXCLUSIONS = 'state_exclusions';

	#[Place]
	public const string STATE_CONSTANTS = 'state_constants';

	#[Place]
	public const string STATE_TEST = 'state_test';

	#[Place]
	public const string STATE_RESULT = 'state_result';

	#[Place]
	public const string STATE_FINISHED = 'state_finished';
	
	// Transitions
	#[Transition(from: self::STATE_QUALIFY, to: self::STATE_EXCLUSIONS)]
	public const string TRANSITION_TO_EXCLUSIONS = 'transition_to_exclusions';

	#[Transition(from: self::STATE_EXCLUSIONS, to: self::STATE_CONSTANTS)]
	public const string TRANSITION_TO_CONSTANTS = 'transition_to_constants';

	#[Transition(from: self::STATE_CONSTANTS, to: self::STATE_TEST)]
	public const string TRANSITION_TO_TEST = 'transition_to_test';

	#[Transition(from: self::STATE_TEST, to: self::STATE_RESULT)]
	public const string TRANSITION_TO_RESULT = 'transition_to_result';
	
	#[Transition(from: self::STATE_RESULT, to: self::STATE_FINISHED)]
	public const string TRANSITION_RESULT_TO_FINISHED = 'transition_result_to_finished';
}  
@Jean-Beru
Copy link
Contributor

I really like the Workflow component, but not his yaml configuration.

I like the idea of using PHP attribute but if the YAML configuration is the problem, why not declaring your workflow using the PHP syntax ?

use App\Entity\Intervention;
use Symfony\Config\FrameworkConfig;

return static function (FrameworkConfig $framework): void {
    $workflow = $framework->workflows()->workflows('intervention_status');

    $workflow
        ->type('state_machine')
        ->supports([Intervention::class])
        ->initialMarking(['state_qualify']);

    $workflow->markingStore()
        ->type('method')
        ->property('currentPlace');

    // defining places manually is optional
    $workflow->place()->name('state_qualify');
    $workflow->place()->name('state_exclusions');
    // ...

    $workflow->transition()
        ->name('to_review')
            ->from(['state_qualify'])
            ->to(['state_exclusions']);
    // ...
};

You will also be able to use enums instead of string.

@thomassebert
Copy link
Author

Yes, that's where I started, and it works well as long as it's not too complicated. As soon as you start having 3 or 4 workflows in a project, the file becomes too long, so you want to separate it into several files or call services, but you don't have services autowire because you're not in a class, so you end up making basic PHP file includes. Likewise, if you want constants (or an enum), you have to separate them in a dedicated class, and you start to end up with 3 files to declare a single workflow (the php config, the class/enum to declare states and the class/enum to declare transitions), which quickly becomes cumbersome.

Hence the idea of passing this to a PHP class that contains the list of states, the list of transitions and the Workflow declaration in the same place. It's easy to read and understand for another developer, or when you come back to the project after 1 year. Other advantages include the flexibility of storing the class wherever you like, and the autowire of our Workflow class with direct access to states, transitions and workflow functions (can(), apply(), ...).

Lastly, it's closer to the way other Symfony components work. For example, for forms, we don't add all our form declarations in a forms.yaml file, which quickly becomes unreadable, nor in a declarative php file. Instead, we create a dedicated class, which is clearer and more readable, and brings other advantages such as form composition, autocompletion, etc...

@stof
Copy link
Member

stof commented Oct 9, 2024

You will also be able to use enums instead of string.

this is not true. Enums are objects in PHP, and so cannot be used in places expecting strings.

You will be able to use constants, though.

@Jean-Beru
Copy link
Contributor

this is not true. Enums are objects in PHP, and so cannot be used in places expecting strings.

I was talking about using backed enums. Ex: Status::Published->value()

@stof
Copy link
Member

stof commented Oct 10, 2024

@Jean-Beru Status::Published->value is still a string, not an enum (and so you are just using a more verbose syntax for string constants. You are not actually using enums)

@thomassebert
Copy link
Author

Thanks for the clarification on enums. To refocus, what do you think of the proposal to create AsWorkflow, Place and Transition attributes to declare workflows using PHP classes? Bearing in mind that this doesn't prevent you from using the classic yaml or php config, it's just another possibility offered to the developer.

@akyoscommunication
Copy link

Up ?

We tested and it looks functional. It's missing metadata but seems easy to add on the attribute.

@lyrixx
Copy link
Member

lyrixx commented Apr 10, 2025

Hello there.

I like the idea a lot !

I thought about something similar few years ago:

#[AsWorkflow(
  name: 'intervention_status'
  places: ['a', ...']
  transitions: [
    new Transition('....']
  ]
)]
class MyWorkflow extends Workflow // The one from symfony

I don't know what is the best way to configure it. Having to declare every singe constant is a bit boring I think. (I never use constant for place or transition 😅. And for thoses telling me I'm crazy: Do you use constants for route and route parameter?)

And with my proposition, you can still use constant, but there is duplicated code...


Anyway, thanks for bringing that up. However, if we merge that into Symfony, we'll have to rework how it works, because we must not use reflection at runtime, and all the autowiring must be keep.

I need to finish to review some issue/pr, and if I have time before the end of the week, I'll try to work on an RFC with the best format

@nicolas-grekas
Copy link
Member

Another idea:

#[AsWorkflowDefinition(initialMarking: 'submitted')]
enum MyWorkflow: string
{
    case Submitted = 'submitted';
    case Ham = 'ham';
    case PotentialSpam = 'potential_spam';
    case Spam = 'spam';
    case Rejected = 'rejected';
    case Published = 'published'; 

    public const ACCEPT = [
        'from' => self::Submitted,
        'to' => self::Ham,
    ];

    public const MIGHT_BE_SPAM = [
        'from' => self::Submitted,
        'to' => self::PotentialSpam,
    ];

    public const REJECT_SPAM = [
        'from' => self::Submitted,
        'to' => self::Spam,
    ];

    public const PUBLISH = [
        'from' => self::PotentialSpam,
        'to' => self::Published,
    ];

    public const REJECT = [
        'from' => self::PotentialSpam,
        'to' => self::Rejected,
    ];

    public const PUBLISH_HAM = [
        'from' => self::Ham,
        'to' => self::Published,
    ];

    public const REJECT_HAM = [
        'from' => self::Ham,
        'to' => self::Rejected,
    ];

    // optional method, by default we take all consts on the enum
    public static function getTransitions(): array
    {
        return [
            self::ACCEPT,
            self::MIGHT_BE_SPAM,
            self::REJECT_SPAM,
            self::PUBLISH,
            self::REJECT,
            self::PUBLISH_HAM,
            self::REJECT_HAM,
        ];
    }
}

@lyrixx
Copy link
Member

lyrixx commented Apr 12, 2025

In a workflow, each places should be unique. But transition names could be duplicated. If you use a constant for that, you'll have a a set name. But you'll have to be creative with the constant name 😬 it defeats a bit the purpose.

@thomassebert
Copy link
Author

Thanks for your responses !

Putting everything in the declaration of the attribute and nothing in the class is like making a yaml config file, you don't have autocompletion, you don't have the syntax of the classes, etc. Why not an enum for the list of states?

Why not an enum for the list of states, but transitions declared as const of type array is also a bit strange. It means that if you have the AsWorkflow attribute then all constants containing an array are necessarily transition definitions, you lose the ability to add other array constants if you need them and unless you use a validation system when loading the class you can't be sure that the “to” and “from” entries are properly formatted. The “Transition” attribute clearly defines the intention, provides the flexibility of setting it to a constant or not, and allows you to type the parameters.

If we take the example of live components, each LiveProp is a property of the class with the #[LiveProp] attribute, and this works quite well. Can we do the same thing and use properties instead of constants?

@GromNaN
Copy link
Member

GromNaN commented Apr 17, 2025

To continue @nicolas-grekas idea, we can attach a #[Transition] attribute to each enum case. This avoids repeating the from and lets you reuse the name. I find this makes the configuration easier to read, as you can see directly which transitions are available for each state.

#[AsWorkflow(initialMarking: self::Submitted)]
enum MyWorkflow: string
{
    #[Transition(to: self::Ham, name: 'accept')]
    #[Transition(to: self::PotentialSpam, name: self::MIGHT_BE_SPAM)]
    #[Transition(to: self::Spam, name: self::REJECT)]
    case Submitted = 'submitted';

    #[Transition(to: self::Published, name: 'publish')]
    #[Transition(to: self::Rejected, name: 'reject')]
    case Ham = 'ham';

    #[Transition(to: self::Published, name: 'publish')]
    #[Transition(to: self::Rejected, name: 'reject')]
    case PotentialSpam = 'potential_spam';

    case Spam = 'spam';
    case Rejected = 'rejected';
    case Published = 'published'; 

    // Using const for transition names is very optional
    public const ACCEPT = 'accept';
    public const MIGHT_BE_SPAM = 'might_be_spam';
    public const PUBLISH = 'publish';
    public const REJECT = 'reject';
}

And I would make it optional to use a backed enum: the case name can be used for concision. If a different string representation is later required, they can switch to backed enum without breaking the existing names.

The transition name can also be optional. You get <from>_to_<to> as default name.

@thomassebert
Copy link
Author

thomassebert commented Apr 17, 2025

I like this proposal! When registering each Transition attribute, maybe it's possible to declare properties or constants on the fly to have autocompletion for Transitions without having to declare additional constants by hand because it's a bit duplicative?

I could spend some time testing this but I don't know enough about how Symfony works at runtime, @lyrixx said ‘we must not use reflection at runtime’, I don't know how else to do it.

@GromNaN
Copy link
Member

GromNaN commented Apr 17, 2025

The configuration needs to be read during the compilation of the container.
Use $container->registerAttributeForAutoconfiguration(AsWorkflow::class, ... to add a tag to every enum. (But I'm not sure enums can be discovered).

Then create a CompilerPass that finds all the definitions for this tag, create the workflow configuration and inject it like this:

// Create Workflow
$workflowDefinition = new ChildDefinition(\sprintf('%s.abstract', $type));
$workflowDefinition->replaceArgument(0, new Reference(\sprintf('%s.definition', $workflowId)));
$workflowDefinition->replaceArgument(1, $markingStoreDefinition);
$workflowDefinition->replaceArgument(3, $name);
$workflowDefinition->replaceArgument(4, $workflow['events_to_dispatch']);
$workflowDefinition->addTag('workflow', ['name' => $name, 'metadata' => $workflow['metadata']]);
if ('workflow' === $type) {
$workflowDefinition->addTag('workflow.workflow', ['name' => $name]);
} elseif ('state_machine' === $type) {
$workflowDefinition->addTag('workflow.state_machine', ['name' => $name]);
}
// Store to container
$container->setDefinition($workflowId, $workflowDefinition);
$container->setDefinition(\sprintf('%s.definition', $workflowId), $definitionDefinition);
$container->registerAliasForArgument($workflowId, WorkflowInterface::class, $name.'.'.$type);
$container->registerAliasForArgument($workflowId, WorkflowInterface::class, $name);

@thomassebert
Copy link
Author

Thanks, I'll try that this week.

@stof
Copy link
Member

stof commented Apr 17, 2025

the API proposed by @GromNaN works for state machines where each transition has a single place for from and to. but it does not work for generic workflows where transitions can have several starting or ending place (a transition starting from ['a', 'b'] is not the same than having a transition starting from ['a'] and another transition starting from ['b'] using the same name)

@thomassebert
Copy link
Author

thomassebert commented May 2, 2025

Hi,

I've tried various things, but none of them have really convinced me. The problem isn't really “how to improve the definition of a workflow”, it's more “how to improve the use of a workflow”. My original proposal was:

  • A workflow can be defined using a PHP class with the AsWorkflow attribute
  • Workflow statuses and transitions are defined using new Place or Transition attributes on class constants

=> It seems that your comments stopped there. And indeed, in this case, an enum and just a Transition attribute like @GromNaN proposal are more appropriate. (@stof and if it covers both state machine / workflow cases because the same transition, i.e. with the same name, can be declared twice, on two different statuses and/or on the same status but with two different “to”).

But the proposal went further than that:

  • The class must extend AbstractWorkflow, which enables useful methods to be added to it (getPlaces(), getTransitions(), canPlaces(), ...), and which can be overridden if required.

  • AbstractWorkflow itself extends Symfony\Component\Workflow, the class that holds the “can”, “apply” and other methods.

  • In this way, when you want to use a workflow, you no longer need to inject WorkflowInterface and correctly name the variable by combining the workflow name with an arbitrary string that no one ever remembers and that requires you to find the doc and config every time, nor to use the Target attribute. Just autowire your class and use it directly. It's simple, efficient and corresponds to what's done for almost everything else on Symfony For example:

public function __construct(
        private UserFirstLoginWorkflow $workflow,
    ) {
    }

/////

 if ($this->workflow->can($user, UserFirstLoginWorkflow::TRANSITION_TO_FIRST_LOGIN)) {
            $this->workflow->apply($user, UserFirstLoginWorkflow::TRANSITION_TO_FIRST_LOGIN);
 }
  • A PHP class is flexible, so you can, for example, add additional methods, such as Guards (and here the attributes already exist), directly in the class concerned. There's no longer any need to scatter the config in one place, the constants in another, and the subscriber in yet another - everything concerning a workflow is grouped together in a single file.

  • Finally, I believe that code should help developers, not frustrate them. Ideas like “to reduce the amount of code to be written as much as possible, we can say that all the constants of a class that has the AsWorkflow attribute are necessarily statuses of this workflow” are counter-productive. Okay, you gain a few lines of config, but you lose clarity: there's a dose of magic, you break the flexibility of being able to declare other const if need be, and you gain autocomplete for statuses but what you really want is autocomplete on transition names $worflow->can( I never remember that TRANSITION_NAME ! ). To keep things clear, flexible and gain full autocomplete in the IDE, you need a PHP class, constants for statuses and constants for transitions. (@lyrixx is that so difficult to not duplicate name for place and transition ? Once you found the place name, juste add 'from' or 'to' and you get the transition name. Maybe a little bit verbose but easy and much more clear).

So back to the original idea. Here's an example of a complete workflow:

<?php

namespace App\Workflow;

use App\Entity\Ticket;
use LeTots\WorkflowExtension\AbstractWorkflow;
use LeTots\WorkflowExtension\Attribute\AsWorkflow;
use LeTots\WorkflowExtension\Attribute\Place;
use LeTots\WorkflowExtension\Attribute\Transition;
use Symfony\Component\Workflow\Attribute\AsGuardListener;
use Symfony\Component\Workflow\Attribute\AsTransitionListener;
use Symfony\Component\Workflow\Event\GuardEvent;
use Symfony\Component\Workflow\Event\TransitionEvent;

#[AsWorkflow(name: self::WORKFLOW_NAME, type: AsWorkflow::TYPE_STATE_MACHINE, supportStrategy: Ticket::class)]
class TicketWorkflow extends AbstractWorkflow
{
	public const WORKFLOW_NAME = 'ticket_status';

	// Places
	#[Place(initial: true)]
	public const string STATE_OPEN = 'state_open';
	#[Place]
	public const string STATE_QUALIFIED = 'state_qualified';
	#[Place]
	public const string STATE_IN_PROGRESS = 'state_in_progress';
	#[Place]
	public const string STATE_PENDING_CUSTOMER_RESPONSE = 'state_pending_customer_response';
	#[Place]
	public const string STATE_TO_BE_VALIDATED = 'state_to_be_validated';
	#[Place]
	public const string STATE_CLOSED = 'state_closed';
	
	// Transitions
	#[Transition(from: self::STATE_OPEN, to: self::STATE_QUALIFIED)]
	public const string TRANSITION_TO_QUALIFIED = 'transition_to_qualified';
	
	#[Transition(from: self::STATE_QUALIFIED, to: self::STATE_IN_PROGRESS)]
	public const string TRANSITION_QUALIFIED_TO_IN_PROGRESS = 'transition_qualified_to_in_progress';
	#[Transition(from: self::STATE_PENDING_CUSTOMER_RESPONSE, to: self::STATE_IN_PROGRESS)]
	public const string TRANSITION_PENDING_CUSTOMER_RESPONSE_TO_IN_PROGRESS = 'transition_pending_customer_response_to_in_progress';
	#[Transition(from: self::STATE_TO_BE_VALIDATED, to: self::STATE_IN_PROGRESS)]
	public const string TRANSITION_TO_BE_VALIDATED_TO_IN_PROGRESS = 'transition_to_be_validated_to_in_progress';
	
	#[Transition(from: self::STATE_IN_PROGRESS, to: self::STATE_PENDING_CUSTOMER_RESPONSE)]
	public const string TRANSITION_TO_PENDING_CUSTOMER_RESPONSE = 'transition_to_pending_customer_response';
	
	#[Transition(from: self::STATE_IN_PROGRESS, to: self::STATE_TO_BE_VALIDATED)]
	public const string TRANSITION_TO_TO_BE_VALIDATED = 'transition_to_to_be_validated';
	
	#[Transition(from: self::STATE_IN_PROGRESS, to: self::STATE_CLOSED)]
	public const string TRANSITION_IN_PROGRESS_TO_CLOSED = 'transition_in_progress_to_closed';
	#[Transition(from: self::STATE_TO_BE_VALIDATED, to: self::STATE_CLOSED)]
	public const string TRANSITION_TO_BE_VALIDATED_TO_CLOSED = 'transition_to_be_validated_to_closed';
	#[Transition(from: self::STATE_PENDING_CUSTOMER_RESPONSE, to: self::STATE_CLOSED)]
	public const string TRANSITION_PENDING_CUSTOMER_RESPONSE_TO_CLOSED = 'transition_pending_customer_response_to_closed';
	
	// Events
	#[AsTransitionListener(workflow: self::WORKFLOW_NAME, transition: self::TRANSITION_TO_PENDING_CUSTOMER_RESPONSE)]
	public function onPendingCustomerResponse(TransitionEvent $event): void
	{
		// Notify customer
	}
	
	#[AsGuardListener(workflow: self::WORKFLOW_NAME, transition: self::STATE_QUALIFIED)]
	public function guardQualified(GuardEvent $event): void
	{
		// Check if customer has a valid ticket subscription or add transition blocker
	}
}

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

No branches or pull requests

8 participants