Dynamic workflows with the Symfony Workflow Component
Introduction
In this short blog post, we will explore how to build dynamic workflows in Symfony by using entities. I’ll guide you through creating a simple yet functional workflow based on the Symfony Workflow Component.
Prerequisites
Before diving in, make sure you have a basic Symfony setup with Doctrine and the Workflow Component installed.
Symfony Workflow Component
The Symfony Workflow Component allows you to define a workflow for an object, guiding it through a series of defined states. This is particularly useful for managing business processes, such as publishing a blog post or processing an order.
Basic Workflow Entities
To illustrate the workflow, we’ll define three essential entities: Place
, Transition
, and BlogPost
.
Example Entities
- Place: Represents a state within the workflow.
class Place { private ?int $id = null; private ?string $name = null; private ?string $target = null; }
- Transition: Defines the movement from one
Place
to another.class Transition { private ?int $id = null; private ?Place $fromPlace = null; private ?Place $toPlace = null; private ?string $name = null; private ?string $target = null; }
- BlogPost: The entity that will be managed by the workflow.
class BlogPost { private ?int $id = null; private ?string $title = null; private ?string $currentState = null; }
We’ll use the target
property in Place
and Transition
to store the target entity (in this case Blogpost), allowing us to dynamically create workflows for different entities.
Building the Custom Workflow
We’ll now build a custom workflow that dynamically registers based on our entities. This process will involve creating a dynamic workflow loader and registering the workflow as a service.
Step 1: Creating the Dynamic Workflow Loader
To build a dynamic workflow in Symfony, we start by creating a service that loads our workflow entities and configures the workflow. Let’s break it down into smaller steps.
1.1 Define the Dynamic Workflow Loader Service
First, we need to define a service class, DynamicWorkflowLoader
, that will handle the creation of the workflow.
namespace App\Service;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Workflow\Registry;
class DynamicWorkflowLoader
{
public function __construct(
private readonly EntityManagerInterface $entityManager
) {
}
}
The service class requires the EntityManagerInterface
to interact with the database.
1.2 Load Workflow Entities from the Database
Next, we’ll fetch the Place
and Transition
entities from the database, which will be used to define the workflow.
private function createWorkflowFromEntities(string $target): Workflow
{
$places =
$this->entityManager
->getRepository(Place::class)
->findBy(['target' => $target]);
$transitions =
$this->entityManager
->getRepository(TransitionEntity::class)
->findBy(['target' => $target]);
}
1.3 Build the Workflow Definition
With the entities loaded, we now use the DefinitionBuilder
to create the workflow definition, specifying the places and transitions.
private function createWorkflowFromEntities(string $target): Workflow
{
...
$definitionBuilder = new DefinitionBuilder();
foreach ($places as $place) {
$definitionBuilder->addPlace($place->getName());
}
foreach ($transitions as $transition) {
$definitionBuilder->addTransition(new Transition(
$transition->getName(),
$transition->getFromPlace()->getName(),
$transition->getToPlace()->getName()
));
}
$definition = $definitionBuilder->build();
}
The DefinitionBuilder
is used to add places (states) and transitions (state changes) to the workflow. Each transition links two places together.
1.4 Configure the Workflow Marking Store
The next step is to configure how the workflow will track the current state of an entity by using a MarkingStore
.
private function createWorkflowFromEntities(string $target): Workflow
{
...
$marking = new MethodMarkingStore(true, 'currentState');
}
The MethodMarkingStore
is configured to store the current state in the currentState
property of the BlogPost
entity. The true
parameter specifies that the marking store should track single states.
1.5 Create and Return the Workflow Instance
Finally, we create and return the Workflow
instance using the definition and marking store we configured.
private function createWorkflowFromEntities(string $target): Workflow
{
...
return new Workflow(
definition: $definition,
markingStore: $marking,
name: 'dynamic_workflow'
);
}
1.6 Expose the Workflow Creation Method
Last step we’re going to do is add a public function that we will call from the factory service to create the workflow.
public function createDynamicWorkflow(string $target): Workflow
{
return $this->createWorkflowFromEntities($target);
}
Step 2: Creating the Workflow Service Factory
The factory will create and register the workflow as a service.
<?php
namespace App\Service;
use Symfony\Component\Workflow\Workflow;
class DynamicWorkflowServiceFactory
{
public function create(DynamicWorkflowLoader $loader, string $target): Workflow
{
return $loader->createDynamicWorkflow($target);
}
}
Step 3: Defining the Service in YAML
Now, we need to define our service in the Symfony configuration.
services:
dynamic_workflow.blogpost:
public: true
class: Symfony\Component\Workflow\WorkflowInterface
factory: ['@App\Service\DynamicWorkflowServiceFactory', 'create']
arguments:
$target: 'App\Entity\BlogPost'
tags:
- { name: workflow }
- { name: workflow.workflow }
- { name: workflow.dynamic_loader, target: 'App\Entity\BlogPost' }
We’re tagging our service with a custom workflow.dynamic_loader
tag to identify it as a dynamic workflow loader. The target
attribute specifies the entity class for which the workflow is being created.
Step 4: Ensuring Proper Registration
Finally, we need to register our workflow in the container. We will do this in the Kernel class, but you could also do it in a compiler pass.
class Kernel extends BaseKernel implements CompilerPassInterface
{
use MicroKernelTrait;
public function boot(): void
{
parent::boot();
}
public function process(ContainerBuilder $container): void
{
foreach($container->findTaggedServiceIds('workflow.dynamic_loader') as $id => $tags){
if(!isset($tags[0]['target'])){
throw new InvalidArgumentException(
sprintf('The target attribute is required on definition %s', $id)
);
}
$target = $tags[0]['target'];
$registryDefinition = $container->getDefinition('workflow.registry');
$strategyDefinition = new Definition(InstanceOfSupportStrategy::class, [$target]);
$registryDefinition->addMethodCall(
'addWorkflow',
[new Reference($id), $strategyDefinition]
);
}
}
}
What we’re doing here, is iterating over all services tagged with workflow.dynamic_loader
, and adding them to the WorkflowRegistry
service.
We’re also adding a InstanceOfSupportStrategy
to the registry, which will ensure that the correct workflow is used for the target entity.
Validating the Workflow
To validate the workflow, we can create a simple test that verifies the workflow is correctly configured.
public function testDynamicWorkflowWorks(): void
{
$entityManager = self::getContainer()->get('doctrine.orm.entity_manager');
$blogpost = new BlogPost();
$blogpost->setTitle('My first blog post');
$entityManager->persist($blogpost);
$entityManager->flush();
/** @var Registry $registry */
$registry = self::getContainer()->get('workflow.registry');
$workflow = $registry->get($blogpost);
$workflow->getMarking($blogpost);
self::assertEquals('draft', $blogpost->getCurrentState());
$workflow->apply($blogpost, 'ready');
$entityManager->persist($blogpost);
$entityManager->flush();
self::assertEquals('to_review', $blogpost->getCurrentState());
self::assertFalse($workflow->can($blogpost, 'publish'));
$workflow->apply($blogpost, 'reviewed');
$entityManager->persist($blogpost);
$entityManager->flush();
self::assertTrue($workflow->can($blogpost, 'publish'));
}
Conclusion
This tutorial provides a basic example of building dynamic workflows in Symfony. By leveraging entities and the Workflow Component, you can create flexible and maintainable workflows tailored to your application’s needs.
Happy coding!