Understanding and Implementing the Workflow Component in Symfony

Vandeth Tho
8 min readJan 27, 2024

--

Symfony Framework

Before reading this blog, you can check another blog of mine: Workflow and the Concept of Order, Structure, and Organization in Crafting a Workflow

This blog is the First part of our workflow component implementation blogs:

  1. Understanding and Implementing the Workflow Component in Symfony
  2. Implementing and Managing Events in Symfony Workflows
  3. Implementing Workflow Component In Symfony With Multiple States Simultaneously

Symfony, a versatile PHP framework, is celebrated for its components that offer reusable features for web applications. The Workflow component, in particular, is an essential tool for managing workflows or life-cycle processes. This article aims to provide a comprehensive guide on how to effectively implement the Workflow component in Symfony.

1. What is the Workflow Component?

The Workflow component in Symfony provides tools for managing a workflow or a series of life-cycle events that an object (like a document or a ticket) goes through. Each step in the workflow is called a “place”. Transitions connect these places, representing the process of moving from one place to another.

Key Concepts

  • Places: These are the states or statuses in a workflow.
  • Transitions: Actions that move the subject from one place to another.
  • Workflow: The overall map of places and transitions.

The Symfony Workflow component supports two types of workflows:

  • Workflow: This type is suitable for use cases where an object can be in multiple states (places) at the same time. It is a more complex scenario suitable for non-linear processes. In a Workflow, an object can have multiple paths and can return to previous states.
  • State Machine: Unlike the Workflow, a State Machine restricts an object to be in only one state (place) at a time. This type is ideal for linear processes or workflows where the progression is straightforward and sequential. In a State Machine, an object moves from one state to another in a defined order, without the possibility of being in multiple states simultaneously or moving backwards.

Today we will start with State Machine. You can use this repo https://github.com/vandetho/blog_app.

In this article, we will try to build a blog publication workflow like the following:

2. Installation

Firstly, we start by installing the component:

composer require symfony/workflow
composer require orm

3. Configuration

Now let setting up our config by updating the config/packages/workflow.yaml:

imports:
- '../workflows/'

By doing so we can regroup our workflow configurations into separate file, for example in our case we can create our config/workflows/blog.yaml:

framework:
workflows:
blog:
type: 'state_machine'
audit_trail:
enabled: true
marking_store:
type: 'method'
property: 'state'
supports:
- App\Entity\Blog
initial_marking: !php/const App\Workflow\State\BlogState::NEW_BLOG
places:
!php/const App\Workflow\State\BlogState::NEW_BLOG:
!php/const App\Workflow\State\BlogState::NEED_REVIEW:
metadata:
bg_color: DeepSkyBlue
!php/const App\Workflow\State\BlogState::NEED_UPDATE:
metadata:
bg_color: Orchid
!php/const App\Workflow\State\BlogState::PUBLISHED:
metadata:
bg_color: Lime
transitions:
!php/const App\Workflow\Transition\BlogTransition::CREATE_BLOG:
from:
- !php/const App\Workflow\State\BlogState::NEW_BLOG
to:
- !php/const App\Workflow\State\BlogState::NEED_REVIEW
!php/const App\Workflow\Transition\BlogTransition::PUBLISH:
from:
- !php/const App\Workflow\State\BlogState::NEED_REVIEW
to:
- !php/const App\Workflow\State\BlogState::PUBLISHED
!php/const App\Workflow\Transition\BlogTransition::NEED_REVIEW:
from:
- !php/const App\Workflow\State\BlogState::PUBLISHED
to:
- !php/const App\Workflow\State\BlogState::NEED_REVIEW
!php/const App\Workflow\Transition\BlogTransition::REJECT:
from:
- !php/const App\Workflow\State\BlogState::NEED_REVIEW
to:
- !php/const App\Workflow\State\BlogState::NEED_UPDATE
!php/const App\Workflow\Transition\BlogTransition::UPDATE:
from:
- !php/const App\Workflow\State\BlogState::NEED_UPDATE
to:
- !php/const App\Workflow\State\BlogState::NEED_REVIEW

You can use PHP constants in YAML files via the !php/const notation. E.g. you can use !php/const App\Workflow\State\BlogState::NEW_BLOGinstead of 'new_blog' or !php/const App\Workflow\Transition\BlogTransition::CREATE_BLOGinstead of 'create_blog'.

Let break down some keys features:

  • blog: This is the name of our workflow which should be unique.
  • type: This is types of workflows that is supported by the components.
  • audit_trail: Setting the enabled option to true makes the application generate detailed log messages for the workflow activity.
  • marking_store: This is where you can set what will be the property and type in the entity where the state will be store.
  • supports: The entity that will be use for the workflow.
  • initial_marking: Where the workflow state is started.
  • places: All of the state that will be available in our workflow.
  • transitions: The section where you define all transition between from a state to another.
This is the structure of src folder, I use

As you can see the create 2 PHP files one is for state and another for transition.

So for my state :

<?php
declare(strict_types=1);

namespace App\Workflow\State;

/**
* Class BlogState
*
* @package App\Workflow\State
* @author Vandeth THO <thovandeth@gmail.com>
*/
final class BlogState
{
public const NEW_BLOG = 'new_blog';
public const NEED_REVIEW = 'need_review';
public const NEED_UPDATE = 'need_update';
public const PUBLISHED = 'published';
}

and for transition:

<?php
declare(strict_types=1);

namespace App\Workflow\Transition;

/**
* Class BlogTransition
*
* @package App\Workflow\Transition
* @author Vandeth THO <thovandeth@gmail.com>
*/
final class BlogTransition
{
public const CREATE_BLOG = 'create_blog';
public const UPDATE = 'update';
public const PUBLISH = 'publish';
public const REJECT = 'reject';
public const NEED_REVIEW = 'need_review';
}

for my entity blog:

<?php
declare(strict_types=1);

namespace App\Entity;

use App\Repository\BlogRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

/**
* Class Blog
*
* @package App\Entity
* @author Vandeth THO <thovandeth@gmail.com>
*/
#[ORM\Entity(repositoryClass: BlogRepository::class)]
class Blog
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

/**
* @var string|null
*/
#[ORM\Column(length: 255, unique: true, nullable: true)]
private ?string $title = null;

/**
* @var string|null
*/
#[ORM\Column(length: 255, unique: true, nullable: true)]
private ?string $titleCanonical = null;

/**
* @var string|null
*/
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $caption = null;

/**
* @var string|null
*/
#[ORM\Column(length: 255)]
private ?string $state = null;

public function getId(): ?int
{
return $this->id;
}

public function getTitle(): ?string
{
return $this->title;
}

public function setTitle(?string $title): Blog
{
$this->title = $title;
return $this;
}

public function getTitleCanonical(): ?string
{
return $this->titleCanonical;
}

public function setTitleCanonical(?string $titleCanonical): Blog
{
$this->titleCanonical = $titleCanonical;
return $this;
}

public function getCaption(): ?string
{
return $this->caption;
}

public function setCaption(?string $caption): Blog
{
$this->caption = $caption;
return $this;
}

public function getState(): ?string
{
return $this->state;
}

public function setState(?string $state): Blog
{
$this->state = $state;
return $this;
}
}

4. Visualizing Workflows

Now you can dump the workflow into an image or SVG with Graphviz, provides the dot command:

# using Graphviz's 'dot' and SVG image
php bin/console workflow:dump blog | dot -Tsvg -o workflows/blog.svg

# using Graphviz's 'dot' and PNG image
php bin/console workflow:dump blog | dot -Tpng -o workflows/blog.png

When running the above command you will find the image or SVG of our blog workflow in the workflows/blog.svg or workflows/blog.png.

5. Using the Workflow

After the above instructions, we can now write some logic for the blog workflow:

<?php
declare(strict_types=1);

namespace App\Workflow;

use App\Entity\Blog;
use App\Repository\BlogRepository;
use App\Workflow\Transition\BlogTransition;
use LogicException;
use Symfony\Component\Workflow\WorkflowInterface;

/**
* Class BlogWorkflow
*
* @package App\Workflow
* @author Vandeth THO <thovandeth@gmail.com>
*/
readonly class BlogWorkflow
{
/**
* BlogWorkflow constructor.
*
* @param WorkflowInterface $blogStateMachine
* @param BlogRepository $blogRepository
*/
public function __construct(
private WorkflowInterface $blogStateMachine,
private BlogRepository $blogRepository,
)
{
}

/**
* Update the blog and send it to be reviewed
*
* @param string $title
* @param string $content
* @return Blog
*/
public function create(string $title, string $content): Blog
{
$blog = $this->blogRepository->create();
$blog->setTitle($title);
$blog->setContent($content);
$this->blogStateMachine->apply($blog, BlogTransition::CREATE_BLOG);
$this->blogRepository->save($blog);
return $blog;
}

/**
* Reject the blog and send it to be updated
*
* @param int $blogId
* @return Blog
*/
public function needReview(int $blogId): Blog
{
$blog = $this->getBlog($blogId);
$this->blogStateMachine->apply($blog, BlogTransition::NEED_REVIEW);
$this->blogRepository->save($blog);
return $blog;
}

/**
* Reject the blog and send it to be updated
*
* @param int $blogId
* @return Blog
*/
public function reject(int $blogId): Blog
{
$blog = $this->getBlog($blogId);
$this->blogStateMachine->apply($blog, BlogTransition::REJECT);
$this->blogRepository->save($blog);
return $blog;

}

/**
* Update the blog and send it to be reviewed
*
* @param int $blogId
* @param string|null $title
* @param string|null $content
* @return Blog
*/
public function update(int $blogId, ?string $title, ?string $content): Blog
{
$blog = $this->getBlog($blogId);
if ($title) {
$blog->setTitle($title);
}
if ($content) {
$blog->setContent($content);
}
$this->blogStateMachine->apply($blog, BlogTransition::UPDATE);
$this->blogRepository->save($blog);
return $blog;
}

/**
* Approve the blog and publish it
*
* @param int $blogId
* @return Blog
*/
public function publish(int $blogId): Blog
{
$blog = $this->getBlog($blogId);
$this->blogStateMachine->apply($blog, BlogTransition::PUBLISH);
$this->blogRepository->save($blog);
return $blog;
}

private function getBlog(int $blogId): Blog
{
$blog = $this->blogRepository->find($blogId);

if ($blog) {
return $blog;
}

throw new LogicException('Blog not found');
}
}

Symfony automatically creates a service for each workflow (Workflow) or state machine (StateMachine) you have defined in your configuration. You can use the workflow inside a class by using service autowiring and using camelCased workflow name + Workflow as parameter name. If it is a state machine type, use camelCased workflow name + StateMachine.

6. Unit Testing Workflows

To test our workflow logic, start by creating a .env.test.local :

# define your env variables for the test env here
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'
SYMFONY_DEPRECATIONS_HELPER=999999
PANTHER_APP_ENV=panther
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
DATABASE_URL="mysql://blog_app:ThisIsATest@127.0.0.1:3306/blog_app?serverVersion=8.0.32&charset=utf8mb4"

Then running the following command to create our database and schema:

# create the test database
php bin/console --env=test doctrine:database:create

# create the tables/columns in test database
php bin/console --env=test doctrine:schema:create

For this test, I will use symfony/test-pack and fakerphp/faker :

composer require --dev symfony/test-pack
composer require --dev fakerphp/faker

Now we can create our testing in tests/Workflow/BlogWorkflowTest.php :

<?php
declare(strict_types=1);

namespace App\Tests\Workflow;

use App\Repository\UserRepository;
use App\Workflow\BlogWorkflow;
use App\Workflow\State\BlogState;
use Faker\Factory;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

/**
* Class BlogWorkflowTest
* @package App\Tests\Workflow
*
* @author Vandeth THO <thovandeth@gmail.com>
*/
class BlogWorkflowTest extends KernelTestCase
{
private BlogWorkflow $blogWorkflow;

protected function setUp(): void
{
$this->blogWorkflow = self::getContainer()->get(BlogWorkflow::class);
}

public function testCreateBlog(): int
{
$faker = Factory::create();
$title = $faker->sentence();
$content = $faker->paragraph();
$blog = $this->blogWorkflow->create($title, $content);

$this->assertNotNull($blog->getId());
$this->assertSame($title, $blog->getTitle());
$this->assertSame($content, $blog->getContent());
$this->assertSame(BlogState::NEED_REVIEW, $blog->getState());
return $blog->getId();
}

/**
* @depends testCreateBlog
*/
public function testReject(int $blogId): int
{
$blog = $this->blogWorkflow->reject($blogId);
$this->assertSame(BlogState::NEED_UPDATE, $blog->getState());
return $blog->getId();
}

/**
* @depends testCreateBlog
*/
public function testUpdate(int $blogId): int
{
$faker = Factory::create();
$title = $faker->sentence();
$content = $faker->paragraph();

$blog = $this->blogWorkflow->update($blogId, $title, $content);
$this->assertSame($title, $blog->getTitle());
$this->assertSame($content, $blog->getContent());
$this->assertSame(BlogState::NEED_REVIEW, $blog->getState());
return $blog->getId();
}

/**
* @depends testCreateBlog
*/
public function testPublish(int $blogId): int
{
$blog = $this->blogWorkflow->publish($blogId);
$this->assertSame(BlogState::PUBLISHED, $blog->getState());
return $blog->getId();
}

/**
* @depends testCreateBlog
*/
public function testNeedReview(int $blogId): int
{
$blog = $this->blogWorkflow->needReview($blogId);
$this->assertSame(BlogState::NEED_REVIEW, $blog->getState());
return $blog->getId();
}
}

Note: In here, to correctly test our service I use @depends annotation and make testCreateBlog return int value in order to test the same entity in all our test.

And do not forget to declare our service public in config/services.yaml :

# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.

# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:

services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.

# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'

# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
App\Workflow\BlogWorkflow:
public: true

By following these steps, you’ll be able to implement and manage workflows in your Symfony application effectively. Remember to refer to the Symfony documentation for the most up-to-date and detailed information.

In our next story, we will talk about Event dispatcher in Workflow Component.

Furthermore, you can simplify and streamline your Symfony Workflow Configuration process by using SymFlowBuilder.

https://www.symflowbuilder.com

--

--

Vandeth Tho

Passionate about technology and innovation, I'm a problem solver. Skilled in coding, design, and collaboration, I strive to make a positive impact.