framework:
workflows:
order:
type: state_machine
marking_store:
type: method
property: currentState
supports:
- App\Entity\Order
initial_marking: cart
places:
- cart
- pending_payment
- paid
- shipped
transitions:
checkout:
from: cart
to: pending_payment
pay:
from: pending_payment
to: paid
ship:
from: paid
to: shipped
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Order
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 20)]
private string $currentState = 'cart';
#[ORM\Column]
private int $total = 0;
#[ORM\Column(nullable: true)]
private ?string $paymentReference = null;
#[ORM\Column(nullable: true)]
private ?string $shippingAddress = null;
public function getId(): ?int
{
return $this->id;
}
public function getCurrentState(): string
{
return $this->currentState;
}
public function setCurrentState(string $state): self
{
$this->currentState = $state;
return $this;
}
public function getTotal(): int
{
return $this->total;
}
public function getPaymentReference(): ?string
{
return $this->paymentReference;
}
public function getShippingAddress(): ?string
{
return $this->shippingAddress;
}
}
<?php
namespace App\Workflow;
use App\Entity\Order;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Workflow\Event\GuardEvent;
class OrderTransitionGuard implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
'workflow.order.guard.pay' => 'guardPay',
'workflow.order.guard.ship' => 'guardShip',
];
}
public function guardPay(GuardEvent $event): void
{
/** @var Order $order */
$order = $event->getSubject();
if ($order->getTotal() <= 0) {
$event->setBlocked(true, 'Order total must be greater than zero.');
return;
}
if (null === $order->getPaymentReference()) {
$event->setBlocked(true, 'A payment reference is required before paying.');
}
}
public function guardShip(GuardEvent $event): void
{
/** @var Order $order */
$order = $event->getSubject();
if (null === $order->getShippingAddress()) {
$event->setBlocked(true, 'Cannot ship an order without a shipping address.');
}
}
}
<?php
namespace App\Controller;
use App\Entity\Order;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Workflow\Exception\NotEnabledTransition;
use Symfony\Component\Workflow\WorkflowInterface;
class OrderController extends AbstractController
{
#[Route('/orders/{id}/transition/{transition}', methods: ['POST'])]
public function transition(
Order $order,
string $transition,
#[Target('order')] WorkflowInterface $orderWorkflow,
EntityManagerInterface $em,
): JsonResponse {
if (!$orderWorkflow->can($order, $transition)) {
$blockers = $orderWorkflow->buildTransitionBlockerList($order, $transition);
$reasons = [];
foreach ($blockers as $blocker) {
$reasons[] = $blocker->getMessage();
}
return $this->json([
'error' => 'Transition not allowed',
'reasons' => $reasons ?: ['No path from current state.'],
], 422);
}
try {
$orderWorkflow->apply($order, $transition);
} catch (NotEnabledTransition $e) {
return $this->json(['error' => 'State changed, retry.'], 409);
}
$em->flush();
return $this->json(['state' => $order->getCurrentState()]);
}
}
This snippet shows how the Symfony Workflow component enforces business rules on an order's lifecycle, using a state machine definition, a guard listener that can veto transitions, and a controller action that applies transitions safely.
The workflow.yaml tab configures a state_machine named order. Unlike a plain workflow, a state machine allows a subject to be in exactly one place at a time, which matches an order that is either cart, pending_payment, paid, or shipped. The marking_store is a method store bound to the entity's currentState property, so the component reads and writes state through getCurrentState() and setCurrentState(). Each transition names its from and to places, forming the only legal paths an order may take. Any transition not listed here is impossible by construction, which is the core value of modeling state explicitly rather than scattering if checks across the codebase.
The Order entity tab is the workflow subject. It exposes the marking accessors the store expects and carries the domain data the guard needs — total, paymentReference, and shippingAddress. Keeping this data on the entity lets guard logic stay declarative and testable.
The OrderTransitionGuard listener tab is where policy lives. It subscribes to workflow.order.guard.pay and workflow.order.guard.ship, the event names the component dispatches before a specific transition fires. Each handler receives a GuardEvent; calling setBlocked() with a reason vetoes the transition without throwing, so the component reports it as not applicable. The pay guard rejects a zero total and a missing paymentReference; the ship guard requires a shippingAddress. This centralizes the invariants that must hold at each hop.
The OrderController tab ties it together. It injects the order workflow via the Target attribute for autowiring by name. Before applying a transition it calls $workflow->can() to check both the current place and every guard; if that fails it surfaces the blocking reason from buildTransitionBlockerList(). Wrapping apply() in a try/catch around NotEnabledTransition guards against races where state changed between the check and the write. This pattern gives a single source of truth for legal transitions, keeps controllers thin, and makes new rules a matter of adding a guard rather than editing scattered conditionals.
Related snips
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
import axios from 'axios';
export type NormalizedErrors = {
fields: Record<string, string>;
formLevel: string | null;
};
Frontend: normalize and display server validation errors
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
Laravel form requests for validation
class SubscriptionsController < ApplicationController
def new
@subscription = current_account.subscriptions.new
end
def create
Turbo Streams: append server-side validation warnings
import { z } from "zod";
const booleanFromString = z.preprocess((val) => {
if (typeof val !== "string") return val;
return ["true", "1", "yes", "on"].includes(val.toLowerCase());
}, z.boolean());
Typed env parsing with zod
Share this code
Here's the card — post it anywhere.