The Symfony workflow component is a very powerful component. Especially when you need to make sure your entities maintain a valid state. This can be very important when, for example, you build an ecommerce system with order handling. In this post we will use the processing of an order as an example.
Setting up the Symfony Workflow Component
To use the workflow component in your application you first need to add it using composer.
composer require symfony/workflow
Code language: JavaScript (javascript)
When this command is done executing you can configure the component using YAML.
# config/packages/workflow.yaml
framework:
workflows:
order:
type: 'state_machine'
audit_trail:
enabled: true
marking_store:
type: 'method'
property: 'status'
supports:
- App\Entity\Order
initial_marking: pending
places:
- pending
- paid
- packed
- shipped
- cancelled
transitions:
pay:
from: pending
to: paid
pack:
from: paid
to: packed
ship:
from: packed
to: shipped
cancel:
from: [pending, paid, packed]
to: cancelled
Code language: PHP (php)
In this example we’ve defined a workflow for our order. We’ve used a state_machine
because our orders can only have one state at a time. Because we’ve enabled the audit_trail
we’ll get detailed logs about the transitions, which can be very useful when debugging the workflow.
The next thing we configure is the supported entity (App\Entity\Order
) and the property in the entity to store the current state in (status
). We set the default status of our orders to pending
using the initial_marking
option. Next we set the possible states (places
) and the transitions
we can do using this workflow.
Of course we need to create the order entity, so here it is, in a very basic form.
namespace App\Entity;
class Order
{
private array $products;
// This property will be managed by the workflow component
private string $status;
// These getters and setters need to exist for the workflow component
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status, $context = []): void
{
$this->status= $status;
}
}
Code language: PHP (php)
Using the symfony workflow component
Now that the workflow component is set up we can use it in our application. To make this easier for you, the developer, symfony creates a service for you named workflow.[workflowname]
in our case this is workflow.order
. You can inject this service, or you can use autowiring to access this service. Just like with the caching component in symfony you can also inject the service using a special parameter name. In this case we can use WorkflowInterface $orderWorkflow
to inject the workflow.
Lets create a service to handle the orders for us.
namespace App\Service;
use App\Entity\Order;
use App\Exception\OrderStateException;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Workflow\WorkflowInterface;
use Symfony\Component\Workflow\Exception\LogicException;
class OrderService
{
private EntityManagerInterface $entityManager;
private WorkflowInterface $orderWorkflow;
public function __construct(EntityManagerInterface $entityManager, WorkflowInterface $orderWorkflow)
{
$this->entityManager = $entityManager;
$this->orderWorkflow = $orderWorkflow;
}
public function pay(Order $order): void
{
$this->doTransition('pay', $order);
// Do extra stuff on the order entity here, like setting payment data
$this->entityManager->flush();
}
public function pack(Order $order): void
{
$this->doTransition('pack', $order);
// Do extra stuff on the order entity here, like setting the packed item amount
$this->entityManager->flush();
}
public function ship(Order $order): void
{
$this->doTransition('ship', $order);
// Do extra stuff on the order entity here, like setting the track and trace code
$this->entityManager->flush();
}
public function cancel(Order $order): void
{
$this->doTransition('cancel', $order);
$this->entityManager->flush();
}
private function doTransition(string $transition, Order $order): void
{
try {
$this->orderWorkflow->apply($order, $transition);
} catch (LogicException $e) {
// Throw a custom exception here and handle this in your controller,
// to show an error message to the user
throw new OrderStateException(sprintf('Cannot change the state of the order, because %s', $e->getMessage()), 0, $e);
}
}
}
Code language: PHP (php)
As you can see we’ve created a function for every transition possible, this makes it very easy for us to call these in our controllers. Because every transition change is basically the same, check if it’s possible and then apply it, we’ve created a private helper method doTransition
. This method will throw custom exceptions which we can catch in our controller to show helpful messages to our users.
Because we make these methods so lean we can leverage the transactions of the doctrine entity manager. A transition is not stored in the database automatically by the workflow component. You will have to do this manually. Using this makes sure we can change other properties of our entity in the same database transaction. So if it fails it will never end up in a corrupted state. Like having status paid
without having payment data stored.
Using the workflow component guarantees the entity is processed in the correct order. An unpaid order will not be able to transition to a packed order. We did this with a little bit of configuration and a few lines of code and of course the Symfony Workflow Component.
Using events to extend your workflow
Let’s go a step further updating our store’s total sales statistics. We could write that functionality in the ship
method of the service, but that will add an extra dependency. To circumvent this we can leverage the power of the event dispatcher of symfony.
To be able to do this the workflow component sends out a lot of events when transitioning from one state to another:
– workflow.guard
– Checking if the state can be changed
– workflow.leave
– The entity leaves a state
– workflow.transition
– An entity is going through a transition
– workflow.enter
– The entity enters a new state
– workflow.entered
– An entity entered a new state
– workflow.completed
– The transition is complete
You can make these events more specific by appending the workflow name and the transition name like this:
– workflow.order.transition.pay
– workflow.order.completed.ship
Let’s create our event subscriber to update the statistics.
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Workflow\Event\Event;
class UpdateOrderStatisticsSubscriber implements EventSubscriberInterface
{
// ... add external dependencies here ...
public function onShippingComplete(Event $event)
{
$order = $event->getSubject();
// ... perform calculations here ...
}
public static function getSubscribedEvents()
{
return [
'workflow.order.completed.ship' => 'onShippingComplete',
];
}
}
Code language: PHP (php)
As you can see, it is really easy to extend the actions performed on an transition using the event system.
Using the guard events
The guard events are a special kind of events in the workflow component. These will be dispatched when the workflow component checks to see if you can execute a transition. With the guard events you can block the transition using external factors.
Lets create a guard event that makes sure only users with the role ROLE_PACKER
can transition an order to packed
.
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Workflow\Event\Event;
class OnlyPackersCanPackAnOrderSubscriber implements EventSubscriberInterface
{
private Security $security;
public function __construct(Security $security)
{
$this->security = $security;
}
public function onGuard(Event $event)
{
$user = $this->security->getUser();
if (null === $user || false === $this->security->isGranted('ROLE_PACKER')) {
$event->setBlocked(true, 'Only warehouse employees can pack orders.');
}
}
public static function getSubscribedEvents()
{
return [
'workflow.order.guard.pack' => 'onGuard',
];
}
}
Code language: PHP (php)
This event listener will make sure only warehouse employees (user with the role ROLE_PACKER) can pack orders. Everyone else will get the error message.
Conclusion
The workflow component is essential when you need to maintain a certain workflow within your application. It makes it really easy to maintain the status of an entity. It’s also really easy to adapt or extend the workflow by changing the configuration.
Have you used the workflow component already in an application? What did you use it for?
Hello, In doTransition, I would not call `$this->orderWorkflow->can($order, $transition)`, since ` $this->orderWorkflow->apply($order, $transition)` already check if it’s possible. So you are running the logic twice, and it decrease performance.
Hi, Thanks for the information! I did not know that. I’ve adjusted the example in the article.
Nice article, thanks. Is the source available on github?
Hi!
Thanks for the compliment. The source is not available on github, since all of the source is in this article.