The Symfony Workflow component explained

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/workflowCode 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:   cancelledCode 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?