Implementing and Managing Events in Symfony Workflows

Vandeth Tho
8 min readJan 29, 2024

--

In my previous article, we understood the fundamentals of implementing the Workflow component in Symfony and the use of workflow type state_machine. Building on that foundation, this article delves deeper into the advanced aspects of workflow management. We’ll explore event handling.

This blog is the second 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

You can find the full code in this repo: https://github.com/vandetho/blog_app.git

1. Introduction to Symfony Events in Workflows

Today we will talk about how to use Symfony’s EventDispatcher component to allow our application to subscribe to and dispatch events throughout the request cycle. By combining this with the Workflow component, you can create subscribers that react to changes in our workflow:

2. Understanding Workflow Events

The Workflow component dispatches various types of events during transitions. Some of the key events are:

  • workflow.guard: Triggered before a transition starts.

The three events being dispatched are:


workflow.guard
workflow.[workflow name].guard
workflow.[workflow name].guard.[transition name]
  • workflow.leave: Occurs when leaving a state.

The three events being dispatched are:

workflow.leave
workflow.[workflow name].leave
workflow.[workflow name].leave.[place name]
  • workflow.transition: Fired during the transition.

The three events being dispatched are:

workflow.transition
workflow.[workflow name].transition
workflow.[workflow name].transition.[transition name]
  • workflow.enter: The subject is about to enter a new place. This event is triggered right before the subject places are updated, which means that the marking of the subject is not yet updated with the new places.

The three events being dispatched are:

workflow.enter
workflow.[workflow name].enter
workflow.[workflow name].enter.[place name]
  • workflow.entered: Occurs after entering a state.

The three events being dispatched are:

workflow.entered
workflow.[workflow name].entered
workflow.[workflow name].entered.[place name]
  • workflow.completed: Fired after a transition is completed.

The three events being dispatched are:

workflow.completed
workflow.[workflow name].completed
workflow.[workflow name].completed.[transition name]
  • workflow.announce: Announced before a transition.

The three events being dispatched are:

workflow.announce
workflow.[workflow name].announce
workflow.[workflow name].announce.[transition name]

After a transition is applied, the announce event tests for all available transitions. That will trigger all guard events once more, which could impact performance if they include intensive CPU or database workloads.

If you don’t need the announce event, disable it using the context:

$workflow->apply($subject, $transitionName, [Workflow::DISABLE_ANNOUNCE_EVENT => true]);

If you initialize the marking by calling $workflow->getMarking($object);, then the workflow.[workflow_name].entered.[initial_place_name] event will be called with the default context (Workflow::DEFAULT_INITIAL_CONTEXT).

3. Configure the workflow

We will improve our previous workflow configuration:

In this workflow, our checking_content place is a condition which will check the blog content when we enter that place, furthermore, we will create a guard event that will prevent the transition some conditions are not meet.

framework:
workflows:
blog_event:
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::CHECKING_CONTENT:
metadata:
bg_color: ORANGE
!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::CHECKING_CONTENT
!php/const App\Workflow\Transition\BlogTransition::VALID:
from:
- !php/const App\Workflow\State\BlogState::CHECKING_CONTENT
to:
- !php/const App\Workflow\State\BlogState::NEED_REVIEW
!php/const App\Workflow\Transition\BlogTransition::INVALID:
from:
- !php/const App\Workflow\State\BlogState::CHECKING_CONTENT
to:
- !php/const App\Workflow\State\BlogState::NEED_UPDATE
!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

As you can see, we will use workflow event to create a condition state, since the workflow component do not offer this feature.

4. Creating Event Listeners and Subscribers

To leverage these events, you need to create event subscribers:

<?php
declare(strict_types=1);

namespace App\EventSubscriber;

use App\Entity\Blog;
use App\Workflow\Transition\BlogTransition;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Workflow\Event\Event;
use Symfony\Component\Workflow\Event\GuardEvent;
use Symfony\Component\Workflow\WorkflowInterface;

/**
* Class BlogEventSubscriber
* @package App\EventSubscriber
* @author Vandeth THO <thovandeth@gmail.com>
*/
readonly class BlogEventSubscriber implements EventSubscriberInterface
{
/**
* BlogEventSubscriber constructor.
*
* @param WorkflowInterface $blogEventStateMachine
*/
public function __construct(
// Note: Our workflow is automatically injected with camelCase name
private WorkflowInterface $blogEventStateMachine
){
}

public static function getSubscribedEvents(): array
{
return [
// Execute when our subject entered the state checking_content
'workflow.blog_event.entered.checking_content' => 'onCheckingContent',
// In here, we will prevent the update transition under certain condition
'workflow.blog_event.guard.update' => 'onGuardUpdate',
];
}

/**
* @param Event $event
* @return void
*/
public function onCheckingContent(Event $event): void
{
/** @var Blog $subject */
$subject = $event->getSubject();
if (strlen($subject->getContent()) <= 200) {
$this->blogEventStateMachine->apply($subject, BlogTransition::INVALID);
return;
}
$this->blogEventStateMachine->apply($subject, BlogTransition::VALID);
}

/**
* @param GuardEvent $event
* @return void
*/
public function onGuardUpdate(GuardEvent $event): void
{
/** @var Blog $subject */
$subject = $event->getSubject();
if (strlen($subject->getContent()) <= 200) {
$event->setBlocked(true, 'This blog content must have more than 200 characters');
}
}
}

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 BlogEventWorkflow
*
* @package App\Workflow
* @author Vandeth THO <thovandeth@gmail.com>
*/
readonly class BlogEventWorkflow
{
/**
* BlogEventWorkflow constructor.
*
* @param WorkflowInterface $blogEventStateMachine
* @param BlogRepository $blogRepository
*/
public function __construct(
private WorkflowInterface $blogEventStateMachine,
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->blogEventStateMachine->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 reject(int $blogId): Blog
{
$blog = $this->getBlog($blogId);
$this->blogEventStateMachine->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|string
*/
public function update(int $blogId, ?string $title, ?string $content): Blog|string
{
$blog = $this->getBlog($blogId);
if ($title) {
$blog->setTitle($title);
}
if ($content) {
$blog->setContent($content);
}
if ($this->blogEventStateMachine->can($blog, BlogTransition::UPDATE)) {
$this->blogEventStateMachine->apply($blog, BlogTransition::UPDATE);
$this->blogRepository->save($blog);
return $blog;
}
return 'This blog content must have more than 200 characters';
}

/**
* Approve the blog and publish it
*
* @param int $blogId
* @return Blog
*/
public function publish(int $blogId): Blog
{
$blog = $this->getBlog($blogId);
$this->blogEventStateMachine->apply($blog, BlogTransition::PUBLISH);
$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->blogEventStateMachine->apply($blog, BlogTransition::NEED_REVIEW);
$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');
}
}

6. Unit Testing Workflows

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

<?php
declare(strict_types=1);

namespace App\Tests\Workflow;

use App\Workflow\BlogEventWorkflow;
use App\Workflow\State\BlogState;
use Faker\Factory;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

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

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

public function testCreateValidBlog(): int
{
$faker = Factory::create();
$title = $faker->sentence();
$content = $faker->paragraphs(4, true);
$blog = $this->blogEventWorkflow->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();
}

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

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

/**
* @depends testCreateValidBlog
*/
public function testReject(int $blogId): void
{
$blog = $this->blogEventWorkflow->reject($blogId);
$this->assertSame(BlogState::NEED_UPDATE, $blog->getState());
}

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

$blog = $this->blogEventWorkflow->update($blogId, $title, $content);
$this->assertSame($blog, 'This blog content must have more than 200 characters');
}

/**
* @depends testCreateValidBlog
*/
public function testUpdate(int $blogId): void
{
$faker = Factory::create();
$title = $faker->sentence();
$content = $faker->paragraphs(4, true);

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

/**
* @depends testCreateValidBlog
*/
public function testPublish(int $blogId): void
{
$blog = $this->blogEventWorkflow->publish($blogId);
$this->assertSame(BlogState::PUBLISHED, $blog->getState());
}

/**
* @depends testCreateValidBlog
*/
public function testNeedReview(int $blogId): void
{
$blog = $this->blogEventWorkflow->needReview($blogId);
$this->assertSame(BlogState::NEED_REVIEW, $blog->getState());
}
}

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

App\Workflow\BlogEventWorkflow:
public: true

7. Choosing which Events to Dispatch

If you prefer to control which events are fired when performing each transition, use the events_to_dispatch configuration option. This option does not apply to Guard events, which are always fired:

framework:
workflows:
blog_event:
type: 'state_machine'
# We only want to dispatch `entered` event
events_to_dispatch: ['workflow.entered']
# pass an empty array to not dispatch any event
# events_to_dispatch: []
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::CHECKING_CONTENT:
metadata:
bg_color: ORANGE
!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::CHECKING_CONTENT
!php/const App\Workflow\Transition\BlogTransition::VALID:
from:
- !php/const App\Workflow\State\BlogState::CHECKING_CONTENT
to:
- !php/const App\Workflow\State\BlogState::NEED_REVIEW
!php/const App\Workflow\Transition\BlogTransition::INVALID:
from:
- !php/const App\Workflow\State\BlogState::CHECKING_CONTENT
to:
- !php/const App\Workflow\State\BlogState::NEED_UPDATE
!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 also disable a specific event from being fired when applying a transition:

$workflow->apply($blog, BlogTransition::CREATE_BLOG, [
Workflow::DISABLE_ANNOUNCE_EVENT => true,
Workflow::DISABLE_LEAVE_EVENT => true,
Workflow::DISABLE_COMPLETED_EVENT => true,
]);

Disabling an event for a specific transition will take precedence over any events specified in the workflow configuration. In the above example the workflow.completed event will not be fired, even if it has been specified as an event to be dispatched for all transitions in the workflow configuration.

These are all the available constants:

  • Workflow::DISABLE_LEAVE_EVENT
  • Workflow::DISABLE_TRANSITION_EVENT
  • Workflow::DISABLE_ENTER_EVENT
  • Workflow::DISABLE_ENTERED_EVENT
  • Workflow::DISABLE_COMPLETED_EVENT

Implementing events in Symfony workflows adds a layer of interactivity and dynamism to your applications, allowing you to respond to state changes in real time and execute custom logic.

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.