This project demonstrates how Symfony workflow (state machine) might be used to split complex business logic into small pieces and ensure system reliability and fault tolerance.
Let's consider the flow: we create an order, send it to the order service, send it to user email and mark it as "sent".
All these actions should be executed successfully, but there are many points of failure: invalid order data during creation, order service or vendor email provider failure, etc
Symfony workflow with retry logic allows to execute the complex business flow even if there are any errors during execution:
- any business logic flow is divided into state-machine transitions. Every transition is executed transactionally.
- if transition execution failed we can retry it later by command or asynchronously.
- every transition contains only business logic and doesn't depend on the workflow implementation.
- a transition uses only two entities:
WorkflowEnvelope
andWorkflowStampInterface
to apply data from previous transitions and pass its own result to the next steps.
As an example, an "Order" workflow is created. The example demonstrates how it's possible to retry the flow after failure:
src/Service/Order/OrderService.php
At first, need to create a new Symfony workflow:
framework:
workflows:
order_send:
type: state_machine
supports:
- App\Entity\WorkflowEntry
marking_store:
type: 'method'
property: 'currentState'
places:
- initialised
- verified
- approved
- sent_to_email
- marked_as_sent
transitions:
verify_order:
from: initialised
to: verified
approve_order:
from: verified
to: approved
send_order_to_email:
from: approved
to: sent_to_email
mark_order_as_sent:
from: sent_to_email
to: marked_as_sent
A workflow handles a App\Entity\WorkflowEntry
object that keeps current and next states and store business logic data using stamps App\Service\Workflow\WorkflowStampInterface
.
Stamps are serialized in the Envelope App\Service\Workflow\Envelope\WorkflowEnvelope
.
The App\Entity\WorkflowEntry
object is stored in database after every transition to allow resuming the process after failures.
App\Service\Workflow\WorkflowHandler
service starts a workflow and handles exceptions.
App\Service\Workflow\EventSubscriber\WorkflowNextStateSubscriber
and App\Service\Workflow\EventSubscriber\WorkflowTransitionSubscriber
use Symfony workflow events to execute current transition, store result in the database and apply the next one.
There are exceptions that allows to control the workflow:
-
App\Service\Workflow\Exception\StopWorkflowException
- finally stops the workflow is some permanent error occurred -
App\Service\Workflow\Exception\WorkflowInternalErrorException
- temporary stops the workflow to retry it later, for example, if 3rd party service is temporarily unavailable -
App\Service\Workflow\Exception\ProceedWorkflowInQueueException
- temporary stops the workflow and send the retry process to a queue
If an exception is thrown then the workflow is marked as failed and make a retry later using a cron command or send workflow execution to a queue. Also, if the error is permanent then the workflow is marked as stopped because it doesn't make sense to retry it.
To implement this flow let's add three transitions. Every transition must implement WorkflowTransitionInterface
.
WorkflowTransitionInterface::handle
method should contain transition implementation.
WorkflowTransitionInterface::getNextTransition
returns next transition value or null if current transition is the last one.
WorkflowTransitionInterface::getState
returns the flow state which should be set to the workflow entry after the transition is done
Check the src/Service/Workflow/Order/Transition
directory for details.
VerifyOrder
class VerifyOrder implements WorkflowTransitionInterface
{
public function __construct(
private readonly OrderRepository $orderRepository,
) {
}
public function handle(WorkflowEnvelope $envelope): WorkflowEnvelope
{
/** @var OrderIdStamp $orderIdStamp */
$orderIdStamp = $envelope->getStamp(OrderIdStamp::class);
$orderId = $orderIdStamp->getOrderId();
$order = $this->orderRepository->find($orderId);
....
return $envelope;
}
public function getNextTransition(): ?string
{
return Transition::ApproveOrder->value;
}
public function getState(): ?string
{
return State::Verified->value;
}
}
ApproveOrder
class ApproveOrder implements WorkflowTransitionInterface
{
public function handle(WorkflowEnvelope $envelope): WorkflowEnvelope
{
....
return $envelope;
}
public function getNextTransition(): ?string
{
return Transition::SendOrderToEmail->value;
}
public function getState(): ?string
{
return State::Approved->value;
}
}
SendOrderToEmail
class SendOrderToEmail implements WorkflowTransitionInterface
{
public function handle(WorkflowEnvelope $envelope): WorkflowEnvelope
{
....
return $envelope;
}
public function getNextTransition(): ?string
{
return Transition::MarkOrderAsSent->value;
}
public function getState(): ?string
{
return State::SentToEmail->value;
}
}
MarkOrderAsSent
class MarkOrderAsSent implements WorkflowTransitionInterface
{
public function handle(WorkflowEnvelope $envelope): WorkflowEnvelope
{
....
return $envelope;
}
public function getNextTransition(): ?string
{
return null;
}
public function getState(): ?string
{
return State::MarkedAsSent->value;
}
}
Because Symfony\Component\Workflow\Registry
injection is deprecated it's convenient to group workflow transitions in the service locator:
app.transitions:
class: Symfony\Component\DependencyInjection\ServiceLocator
tags: [ 'container.service_locator' ]
arguments:
-
order_send.verify_order: '@App\Service\Workflow\Order\Transition\VerifyOrder'
order_send.approve_order: '@App\Service\Workflow\Order\Transition\ApproveOrder'
order_send.send_order_to_email: '@App\Service\Workflow\Order\Transition\SendOrderToEmail'
order_send.mark_order_as_sent: '@App\Service\Workflow\Order\Transition\MarkOrderAsSent'
and pass them to the App\Service\Workflow\EventSubscriber\WorkflowTransitionSubscriber
App\Service\Workflow\EventSubscriber\WorkflowTransitionSubscriber:
arguments:
$transitions: '@app.transitions'
Everything is ready! Now we only have to create a App\Entity\WorkflowEntry
object with a order_send
type and run it using App\Service\Workflow\WorkflowHandler::handle
method
class OrderService
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly WorkflowHandler $workflowHandler,
private readonly OrderSendWorkflowBuilder $orderSendWorkflowBuilder,
) {
}
public function createOrder(): Order
{
$order = new Order();
$order->setDescription('my order');
$this->entityManager->persist($order);
$this->entityManager->flush();
$this->workflowHandler->handle(
$this->orderSendWorkflowBuilder->create($order)
);
return $order;
}
}
class OrderSendWorkflowBuilder
{
public function __construct(
private readonly NormalizerInterface $normalizer,
) {
}
public function create(
Order $order
): WorkflowEntry {
$envelope = new WorkflowEnvelope(
[
OrderIdStamp::createWithOrderId($order->getId()),
]
));
/** @var array $stamps */
$stamps = $this->normalizer->normalize($envelope, WorkflowEnvelope::class);
return WorkflowEntry::create(
WorkflowType::OrderSend,
Transition::VerifyOrder->value,
$stamps
);
}
}