Introduction to Symfony Workflow

Nestor Brito Medina
8 min readApr 13, 2024

--

Sometimes (often), we have to deal with a Workflow for an Entity/Model in our projects, which includes some business logic and rules to allow transitions between states.

This is normally accomplished by applying the State Machine pattern, but Symfony has packaged a great component called Symfony Workflow (link at the end of the article) that implements this pattern for us.

Test case scenario

Suppose you have to write a REST API for a blog and you need to validate the transitions between the states of a Blog Post, from when it is created to when it is published, as the Blog Post states have a logical order and the transition from one state to another must follow this order.

Let’s say that you have this “BlogPost” entity:

<?php

namespace App\Entity;

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

#[ORM\Entity(repositoryClass: BlogPostRepository::class)]
class BlogPost
{
public const STATUS_DRAFT = 'draft';
public const STATUS_REVIEWED = 'reviewed';
public const STATUS_REJECTED = 'rejected';
public const STATUS_PUBLISHED = 'published';
public const STATUS_ARCHIVED = 'archived';

#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\Column(length: 255)]
private ?string $title = null;

#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $excerpt = null;

#[ORM\Column(type: Types::TEXT)]
private ?string $content = null;

#[ORM\Column(length: 255)]
private ?string $author = null;

#[ORM\Column(type: Types::DATETIME_MUTABLE)]
private ?\DateTimeInterface $dateCreated;

#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $datePublished = null;

#[ORM\Column(length: 255)]
private ?string $status = self::STATUS_DRAFT;

public function __construct()
{
$this->dateCreated = new \DateTime('now');
}


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

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

public function setTitle(string $title): static
{
$this->title = $title;

return $this;
}

public function getExcerpt(): ?string
{
return $this->excerpt;
}

public function setExcerpt(?string $excerpt): static
{
$this->excerpt = $excerpt;

return $this;
}

public function getContent(): ?string
{
return $this->content;
}

public function setContent(string $content): static
{
$this->content = $content;

return $this;
}

public function getAuthor(): ?string
{
return $this->author;
}

public function setAuthor(string $author): static
{
$this->author = $author;

return $this;
}

public function getDateCreated(): ?\DateTimeInterface
{
return $this->dateCreated;
}

public function setDateCreated(\DateTimeInterface $dateCreated): static
{
$this->dateCreated = $dateCreated;

return $this;
}

public function getDatePublished(): ?\DateTimeInterface
{
return $this->datePublished;
}

public function setDatePublished(?\DateTimeInterface $datePublished): static
{
$this->datePublished = $datePublished;

return $this;
}

public function getStatus(): ?string
{
return $this->status;
}

public function setStatus(string $status): static
{
$this->status = $status;

return $this;
}

public function getCurrentStatus(): array
{
return [
$this->getStatus() => $this->getStatus(),
];
}
}

Our BlogPost entity have a property “$status” that by default, when a new BlogPost is created, it will start with a “draft” state:

private ?string $status = self::STATUS_DRAFT;

Also, we have two endpoints, one for create blog posts and another to change the state of an existing blog post.

Create BlogPost

An Open API specification with details of the Create BlogPost endpoint such as path, request body and response body.

Update BlogPost

An Open API specification with details of the Update BlogPost endpoint such as path, request body and response body.

And these are the allowed states for a BlogPost:

Implementing States Machine using Symfony Workflow

Installing and configuring Symfony Workflow

For installing Symfony Workflow, the recommended way is using Composer, so, you have to execute this command:

composer require symfony/workflow

If you are using Symfony and Symfony Flex, the configuration file for this bundle will be created by default into “config/packages/workflow.yaml”.

Before keep going with the configuration, is necessary to explain some basic concepts about this bundle.

Places and Transitions

Symfony Workflow introduces two important concepts: Places and Transitions.

Places are the states where an entity may reside at a given point. For example, the states “draft” and “published” are places where the entity BlogPost may reside at some point. So, places are equivalent to states.

Transitions are the defined paths for moving from one place (state) to another, and they determine the allowed paths between states. If a transition between the places (states) “draft” and “published” does not exist, you simply will never be able to move from the “draft” to the “published” place, as they are not connected via a transition.

Now that these concepts are clear, let’s explore how to configure a complete Workflow for the BlogPost entity to manage its state transitions.

In the file “config/packages/workflow.yaml” created earlier during the installation step, we have the following configuration:

framework:
workflows:
blog_publishing:
type: 'state_machine'
audit_trail:
enabled: true
marking_store:
type: 'method'
property: 'status'
supports:
- App\Entity\BlogPost
initial_marking: !php/const App\Entity\BlogPost::STATUS_DRAFT
places:
- !php/const App\Entity\BlogPost::STATUS_DRAFT
- !php/const App\Entity\BlogPost::STATUS_REVIEWED
- !php/const App\Entity\BlogPost::STATUS_REJECTED
- !php/const App\Entity\BlogPost::STATUS_PUBLISHED
- !php/const App\Entity\BlogPost::STATUS_ARCHIVED
transitions:
reviewed:
from: !php/const App\Entity\BlogPost::STATUS_DRAFT
to: !php/const App\Entity\BlogPost::STATUS_REVIEWED
published:
from: !php/const App\Entity\BlogPost::STATUS_REVIEWED
to: !php/const App\Entity\BlogPost::STATUS_PUBLISHED
rejected:
from: !php/const App\Entity\BlogPost::STATUS_REVIEWED
to: !php/const App\Entity\BlogPost::STATUS_REJECTED
archived:
from:
- !php/const App\Entity\BlogPost::STATUS_DRAFT
- !php/const App\Entity\BlogPost::STATUS_REJECTED
to: !php/const App\Entity\BlogPost::STATUS_ARCHIVED

Despite the self-explanatory comments inside the YAML file, we’ll clarify some parts of the above configuration.

blog_publishing: This serves as the name of the workflow. You can add any number of workflows under the framework.workflows key.

type: This specifies the type of the workflow, choosing between “workflow” or “state_machine”. The distinction between each type lies in the fact that in a workflow, the entity must have been in all the previous states before moving to the next one, whereas in a state_machine, the transition only validates movement from one single state to another.

For our specific use case, if you need to validate that a BlogPost has been in both the “draft” and “reviewed” states before publishing it, you may prefer to use a workflow type in your configuration and store all the previous states that the BlogPost entity has been in. You can delve deeper into this topic via the link provided at the end of this post.

audit_trail: Setting this to “enabled: true” allows the application to generate detailed log messages for the workflow activity.

marking_store: Under “marking_store.property”, you define the property of the entity that will hold its current state, or an array of states (in case you’ve set the type as workflow instead of state_machine). And under “marking_store.type”, you specify the type of the property. In our case, it’s a method (refer to “setStatus()” and “getStatus()” methods in the BlogPost entity).

places: This section holds a list of all the possible places (states) for the entity.

transitions: Here, you define all the valid transitions between places. The first key below “transitions” is the name of the transition, followed by “from” and “to” keys. As the names suggest, the “from” key contains the origin state or states of the transition, while the “to” key defines the destination state or states of the transitions.

Once the configuration is complete, let’s review the “BlogPostChangeStateHandler.php” class, which is a Service responsible for handling the logic to change the state for a given BlogPost.

Handling state transitions inside a service

We have this Use Case Handler for the BlogPostChangeState Use Case:

<?php

namespace App\UseCase;

use App\Entity\BlogPost;
use App\Exception\BlogPostNotFoundException;
use App\Exception\BlogPostStateIsNotAllowedException;
use App\UseCase\Request\AbstractDto;
use App\UseCase\Request\BlogPostChangeStateRequest;
use App\UseCase\Response\BlogPostChangeStateResponse;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\Workflow\WorkflowInterface;

class BlogPostChangeStateHandler
{
public function __construct(
private EntityManagerInterface $entityManager,
#[Target('blog_publishing')]
private WorkflowInterface $blogPublishingWorkflow
) {
}

/**
* @throws BlogPostNotFoundException
* @throws BlogPostStateIsNotAllowedException
*/
public function __invoke(AbstractDto $blogPostChangeStateRequest): Dto\BlogPostDto
{
if (!$blogPostChangeStateRequest instanceof BlogPostChangeStateRequest) {
throw new \LogicException('Invalid object type of '.$blogPostChangeStateRequest::class);
}

$blogPost = $this->entityManager->find(BlogPost::class, $blogPostChangeStateRequest->id);

if (empty($blogPost)) {
throw new BlogPostNotFoundException($blogPostChangeStateRequest->id);
}

$blogPost = $this->updateBlogPostState($blogPost, $blogPostChangeStateRequest->newState);

$this->entityManager->persist($blogPost);
$this->entityManager->flush();


return new BlogPostChangeStateResponse(
id: $blogPost->getId(),
title: $blogPost->getTitle(),
excerpt: $blogPost->getExcerpt(),
content: $blogPost->getContent(),
author: $blogPost->getAuthor(),
status: $blogPost->getStatus()
);
}

/**
* @throws BlogPostStateIsNotAllowedException
*/
private function updateBlogPostState(BlogPost $blogPost, string $newBlogPostState): BlogPost
{
if (!$this->blogPublishingWorkflow->can($blogPost, $newBlogPostState)) {
throw new BlogPostStateIsNotAllowedException($newBlogPostState);
}

$this->blogPublishingWorkflow->apply($blogPost, $newBlogPostState);

return $blogPost;
}
}

This class will be called from a controller, receiving a DTO $blogPostChangeStateRequest as an argument that will contains the ID of the BlogPost to update and the new state:

<?php

namespace App\UseCase\Request;

class BlogPostChangeStateRequest extends AbstractDto
{
public function __construct(
public int $id,
public ?string $newState
) {
}

public static function createFromArray(array $data): AbstractDto
{
return new self(
id: $data['id'],
newState: $data['newState'] ?? null
);
}
}

Another place to look at is the constructor method of the BlogPostChangeStateHandler class:

public function __construct(
private EntityManagerInterface $entityManager,
#[Target('blog_publishing')]
private WorkflowInterface $blogPublishingWorkflow
) {
}

Note that we are injecting the “blog_publishing” workflow as a service through the Target attribute. This is available since Symfony 6.3. If you’re using a prior version of Symfony, you can inject a workflow services this way:

// Symfony will inject the 'blog_publishing' workflow configured before
public function __construct(WorkflowInterface $blogPublishingWorkflow)
{
$this->blogPublishingWorkflow = $blogPublishingWorkflow;
}

So, moving forward, let’s see now the method that performs the state transition, using the “$blogPublishingWorkflow” service inside our BlogPostChangeStateHandler class:

/**
* @throws BlogPostStateIsNotAllowedException
*/
private function updateBlogPostState(BlogPost $blogPost, string $newBlogPostState): BlogPost
{
if (!$this->blogPublishingWorkflow->can($blogPost, $newBlogPostState)) {
throw new BlogPostStateIsNotAllowedException($newBlogPostState);
}

$this->blogPublishingWorkflow->apply($blogPost, $newBlogPostState);

return $blogPost;
}

This method takes two arguments: a BlogPost instance retrieved from our database and a string value representing the next state to which we want to move our entity.

Primarily, we’ll rely on two methods: can() and apply().

The “can()” method validates whether the BlogPost instance passed can transition to the state specified in “$newBlogPostState”. It assesses this based on two factors: the current state of the BlogPost instance and the transitions defined in the configuration for the “blog_publishing” workflow.

If the transition is permitted, the method returns “true”; otherwise, it returns “false”.

You can perform your own custom logic for validate a transition when the method can() is called, using event listeners or subscribers, but this will be covered in another post with more advanced Symfony Workflow use cases.

Once the transition has been validated, you must execute the transition and update the new state in the BlogPost entity. To achieve this, you call the “apply()” method, which simply sets the new state in the given object.

It’s important to note that the status update is only in memory. You need to persist the object in order to update it in the database, as demonstrated in this section of the BlogPostChangeStateHandler class:

$blogPost = $this->updateBlogPostState($blogPost, $blogPostChangeStateRequest->newState);

$this->entityManager->persist($blogPost);
$this->entityManager->flush();

Conclusions

Symfony Workflow component is a powerful tool that handles complex state transitions in an easy and organized manner. It provides a clear and descriptive way of defining a state machine, offering a lot of flexibility and allowing us to define custom logic for validating transitions, which can be extremely useful for complex use cases.

Furthermore, Symfony Workflow’s clear and intuitive syntax allows for easy configuration of workflows and state machines. This includes the definition of ‘places’ (states) and ‘transitions’ (pathways between states). This means that you can visually map out the entire lifecycle of an entity in your system, making it easier to understand and manage.

The component also supports event-driven development, allowing developers to create listeners and subscribers for workflow events. This can be particularly useful when you need to trigger specific actions when a transition occurs.

In summary, Symfony Workflow component is a robust and flexible tool for managing complex workflows and state machines in your PHP applications. Whether you’re developing a simple blog or a complex e-commerce system, Symfony Workflow can help you ensure that entity states are managed in a consistent and reliable manner.

Resources

[Workflows and State Machines] https://symfony.com/doc/6.4/workflow/workflow-and-state-machine.html

[Symfony Workflow] https://symfony.com/doc/6.4/components/workflow.html

You can find the entire source code used in this article on the fallowing GitHub repository, please give a star if you found it useful 😉

https://github.com/necobm/symfony-workflow-lab

--

--

Nestor Brito Medina

Software Engineer and entrepreneur passionate about life and technology.