Dynamic workflows with the Symfony Workflow Component

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

  1. Place: Represents a state within the workflow.
    class Place
    {
        private ?int $id = null;
        private ?string $name = null;
        private ?string $target = null;
    }
    
  2. 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;
    }
    
  3. 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!

Sources