Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[Workflow] Allow to define workflow with PHP attributes - #61935

Open
lyrixx wants to merge 1 commit into
symfony:8.2from
lyrixx:workflow-new-definition
Open

[Workflow] Allow to define workflow with PHP attributes#61935
lyrixx wants to merge 1 commit into
symfony:8.2from
lyrixx:workflow-new-definition

Conversation

@lyrixx

@lyrixx lyrixx commented Oct 2, 2025

Copy link
Copy Markdown
Member
Q A
Branch? 8.1
Bug fix? no
New feature? yes
Deprecations?
Issues Fix #58503
License MIT

Hello folks!

I'm happy to share with you a new way to configure workflow. Please read the issue first.

Here is the new API:

namespace App\Workflow;

use App\Entity\Model\TaskStep;
use Symfony\Component\Workflow\Attribute\AsWorkflow;
use Symfony\Component\Workflow\Attribute\Transition;
use Symfony\Component\Workflow\WorkflowTrait;

#[AsWorkflow(
    name: 'task',
    supports: [\App\Entity\Task::class],
    places: TaskStep::class, // Not needed, but works!
    metadata: ['foobar => 'bar']
)]
class TaskWorkflow
{
    use WorkflowTrait;

    #[Transition(
        froms: TaskStep::New,
        tos: TaskStep::Processing,
        metadata: ['foobar => 'bar'],
    )]
    public const string START_PROCESS = 'start_process';
enum TaskStep: string
{
    #[Place(
        metadata: [
            'label' => 'New',
            'description' => 'The task is newly created and not yet processed'
        ],
    )]
    case New = 'new';
class TaskController extends AbstractController
{
    public function __construct(
        private readonly EntityManagerInterface $em,
        private readonly TaskWorkflow $stateMachine,
    ) {
    }


    #[Route(path: '/apply-transition/{id}', methods: ['POST'], name: 'task_apply_transition')]
    public function applyTransition(Request $request, Task $task): Response
    {
        try {
            $this->stateMachine->apply($task, (string) $request->request->get('transition'));

As you can see, there is a new AsWorkflow Attribute. Thanks to it, you can configure everything about
the workflow (name, metatada, support, auditTrail, etc). As usual, places can be inferred from the transitions (from and to)

Then, in the class, you can add constant to declare transitions.

The PR is not finished yet. But I want to gather feedback first

Comment thread src/Symfony/Component/Workflow/Tests/Configuration/AttributeReaderTest.php Outdated
Comment thread src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php Outdated
Comment thread src/Symfony/Component/Workflow/DependencyInjection/WorkflowServiceCreatorPass.php Outdated

$registryDefinition = $container->getDefinition('workflow.registry');

$config = $container->getParameter('.workflow.config');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Having a compiler pass defined in the component that processes a parameter storing the semantic configuration of FrameworkBundle leads to new BC guarantees we need to provide, as it means that this config is not purely internal anymore. It crosses a package boundary.
I would prefer keeping the FrameworkBundle config structure purely internal (we provide BC for the input of our Configuration classes, not for its output, as this would forbid us to make many changes we do when deprecating things)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

So I need to create a shape that is stored in the component, and the framework bundle will convert its internal structure to that shape?

LGTM

if (!$workflow = $attributes[0]['configuration'] ?? null) {
throw new LogicException(\sprintf('The service "%s" must define the "configuration" attribute on its "%s" tag.', $id, '.workflow.attribute'));
}
$serviceId = $this->createWorkflow($container, $registryDefinition, $workflow['name'], $workflow);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why is the name part of the configuration attribute while used separately, instead of being a separate tag attribute ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I don't understand you sentence :/

The PHP attribute looks like this:

#[AsWorkflow(
    name: 'task',
)]

It's converted to an array of configuration, and it's passed as a DIC attribute of the tag:

$container->registerAttributeForAutoconfiguration(AsWorkflow::class, static function (ChildDefinition $definition, AsWorkflow $attribute, \ReflectionClass $reflection) use ($attributeReader): void {
    $configuration = $attributeReader->extractConfiguration($attribute, $reflection);
    $definition->addTag('.workflow.attribute', [
        'configuration' => $configuration,
    ]);
});

On the line you commented, the name part is coming from the configuration.

$serviceId = $this->createWorkflow($container, $registryDefinition, $workflow['name'], $workflow);
$container
->getDefinition($serviceId)
->clearTag('.workflow.attribute')

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why clearing this tag ? That definition created just above will not have this tag.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It's not needed anymore. It's just internal stuff. There is no need to expose that, and have to maintain a BC on this

Comment thread src/Symfony/Component/Workflow/Attribute/AsWorkflow.php
Comment thread src/Symfony/Component/Workflow/Configuration/AttributeReader.php
Comment thread src/Symfony/Component/Workflow/Configuration/AttributeReader.php Outdated
Comment thread src/Symfony/Component/Workflow/Configuration/AttributeReader.php Outdated
Comment thread src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php Outdated
Comment thread src/Symfony/Component/Workflow/Attribute/Transition.php Outdated
Comment thread src/Symfony/Component/Workflow/Attribute/Transition.php Outdated
Comment thread src/Symfony/Component/Workflow/Attribute/Transition.php Outdated
Comment thread src/Symfony/Component/Workflow/Configuration/AttributeReader.php Outdated
Comment thread src/Symfony/Component/Workflow/Configuration/AttributeReader.php Outdated
@Menelion

Menelion commented Oct 6, 2025

Copy link
Copy Markdown

Hallelujah! Hope it will get merged soon, thanks @lyrixx for this work! I love the concept of workflows but hate the necessity to write that Yaml config.

@lyrixx
lyrixx force-pushed the workflow-new-definition branch from e70dc8d to 07bcd03 Compare October 22, 2025 15:49
@lyrixx

lyrixx commented Oct 22, 2025

Copy link
Copy Markdown
Member Author

I pushed a new version :

  • The class that hold AsWorkflow attribute does not need to extends anything
  • Optionally, if it use WorkflowTrait, it will behave like a Workflow.
  • Add support for workflow metadata
  • Add support for place metadata
  • Add support for many transition with the same name

@lyrixx
lyrixx force-pushed the workflow-new-definition branch from 07bcd03 to 5a40613 Compare October 22, 2025 15:54
@lyrixx
lyrixx force-pushed the workflow-new-definition branch from 5a40613 to 73a4d5e Compare October 22, 2025 16:17
@nicolas-grekas nicolas-grekas modified the milestones: 7.4, 8.1 Nov 16, 2025
@nicolas-grekas nicolas-grekas modified the milestones: 8.1, 8.2 May 6, 2026

@alexandre-daubois alexandre-daubois left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good otherwise, great addition!

$definition = $container->getDefinition($id);
$definition->clearTag('.workflow.attribute');
$reflection = $container->getReflectionClass($definition->getClass());
if (\in_array(WorkflowTrait::class, $reflection->getTraitNames(), true)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

getTraitNames() only reports traits used directly by the class, so a class that picks WorkflowTrait up from a parent or from another trait never gets the setWorkflow() call: abstract class Base { use WorkflowTrait; } with #[AsWorkflow(name: 'task')] class TaskWorkflow extends Base {} compiles with state_machine.task created and zero method calls on the service, then throws "The workflow has not been set" on the first apply(). You could test $reflection->hasMethod('setWorkflow') instead, or walk the parents and the traits of traits.

{
public function process(ContainerBuilder $container): void
{
if (!$container->hasDefinition('workflow.registry')) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This early return turns every #[AsWorkflow] class into a silent no-op when workflow.registry is absent, and framework.workflows is canBeEnabled() so it is off by default. An app configured through attributes alone compiles with the .workflow.attribute tag still on the definition, no workflow service and no setWorkflow() call, so it may be worth throwing here when tagged services exist, with a message naming framework.workflows.enabled.

}

// Store to container
$container->setDefinition($workflowId, $workflowDefinition);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Service ids come from type and name alone, so two workflows sharing both collapse into one set of definitions with no diagnostic: two classes carrying #[AsWorkflow(name: 'task')] yield a single state_machine.task holding the second class's transitions, while the registry gets addWorkflow for both supports classes pointing at it. Guarding with $container->hasDefinition($workflowId) and throwing on a collision would surface it at compile time.

if (\is_string($value)) {
$values[$k] = [
'place' => $value,
'weighted' => 1,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Normalization emits a weighted key while the consumer reads $arc['weight'] ?? 1 and the YAML arc schema also uses weight, so any weight expressed on an attribute arc is dropped when Arc is built. Renaming the key to weight would align the two paths, and AttributeReaderTest needs the matching assertion since it currently pins weighted.

public array $supports = [],
public array $markingStore = [],
public array $metadata = [],
public bool $auditTrail = true,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

auditTrail defaults to true while the audit_trail config node is canBeEnabled() and off by default, so #[AsWorkflow(name: 'task', supports: [...])] alone registers an AuditTrailListener on leave, transition and enter. Defaulting to false would keep the two configuration formats behaving the same.

$this->workflow = $workflow;
}

public function getMarking(object $subject): Marking

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This drops the array $context parameter that Workflow::getMarking() accepts, and getEnabledTransition() has no delegation at all even though WorkflowInterface declares it on the target branch. Code moving from an injected WorkflowInterface to the trait loses both and cannot reach the private $workflow, so mirroring the current interface signatures here would help.

) {
if (\is_string($this->places)) {
if (!enum_exists($this->places)) {
throw new \InvalidArgumentException(\sprintf('The "places" attribute of the "%s" workflow must be an array or a valid enum name, "%s" given.', self::class, $this->places));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Both places exceptions interpolate self::class, which renders as the attribute FQCN rather than the workflow being configured, so a bad places string fails with a message naming Symfony\Component\Workflow\Attribute\AsWorkflow and never the offending class. Using $this->name would point at the right workflow.

return;
}

foreach ($this->places as $k => $place) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Normalization fills in metadata for string places only, so places: [['name' => 'draft']] reaches if ($place['metadata']) in WorkflowServiceCreatorPass and raises an "Undefined array key" warning during compilation. Applying $place['metadata'] ??= [] in the array branch covers it.

}

$this->assertSame(UnusedTagsPassUtils::getDefinedTags(), $this->getKnownTags(), 'The src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php file must be updated; run src/Symfony/Bundle/FrameworkBundle/Resources/bin/check-unused-known-tags.php.');
$this->assertEquals(UnusedTagsPassUtils::getDefinedTags(), $this->getKnownTags(), 'The src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php file must be updated; run src/Symfony/Bundle/FrameworkBundle/Resources/bin/check-unused-known-tags.php.');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Both sides are sorted lists of strings and compare identical under === on this branch, so switching to assertEquals only loosens the check without fixing anything. You can keep assertSame here.

$tags = [
'proxy' => true,
'routing.controller' => true,
'.workflow.attribute' => true,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The third scan already discovers .workflow.attribute from the findTaggedServiceIds('.workflow.attribute', true) call in WorkflowServiceCreatorPass, which sits under a DependencyInjection path inside src/Symfony. Dropping the hardcoded entry keeps the list honest if the tag later loses its consumer.

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.

[Workflow] Declare Workflow with PHP Class and attributes

7 participants