[Workflow] Allow to define workflow with PHP attributes - #61935
Conversation
|
|
||
| $registryDefinition = $container->getDefinition('workflow.registry'); | ||
|
|
||
| $config = $container->getParameter('.workflow.config'); |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
why is the name part of the configuration attribute while used separately, instead of being a separate tag attribute ?
There was a problem hiding this comment.
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') |
There was a problem hiding this comment.
why clearing this tag ? That definition created just above will not have this tag.
There was a problem hiding this comment.
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
|
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. |
e70dc8d to
07bcd03
Compare
|
I pushed a new version :
|
07bcd03 to
5a40613
Compare
5a40613 to
73a4d5e
Compare
alexandre-daubois
left a comment
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
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')) { |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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)); |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.'); |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
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:
As you can see, there is a new
AsWorkflowAttribute. Thanks to it, you can configure everything aboutthe 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