symfony/workflow
The Symfony Workflow component provides tools for managing workflows and finite state machines in PHP applications, letting you define states, transitions, and guards to model business processes cleanly and reliably.
Install the Package
composer require symfony/workflow
For Laravel integration, use symfony/workflow alongside symfony/dependency-injection and symfony/http-kernel (if needed for DI). Current stable line is 8.1.x (latest patch v8.1.8).
Define a Workflow
Create a YAML/array definition for your workflow (e.g., order_workflow.yaml):
# config/workflows/order_workflow.yaml
app.order_workflow:
supports:
- App\Models\Order
initial_marking: draft
places:
draft:
transitions:
submit: pending
pending:
transitions:
approve: processing
reject: rejected
processing:
transitions:
ship: shipped
shipped:
transitions:
deliver: delivered
rejected:
transitions: ~
Register the Workflow in Laravel Use Laravel’s service provider to load the workflow:
// app/Providers/WorkflowServiceProvider.php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Symfony\Component\Workflow\Definition;
use Symfony\Component\Workflow\MarkingStore\MethodMarkingStore;
use Symfony\Component\Workflow\Registry;
use Symfony\Component\Workflow\Workflow;
class WorkflowServiceProvider extends ServiceProvider
{
public function register()
{
$this->app->singleton(Registry::class, function ($app) {
$definition = $app['config']['workflows.order_workflow'];
$store = new MethodMarkingStore(true, 'status'); // single state field
$registry = new Registry();
$registry->addWorkflow(
'order_workflow',
new Workflow($definition, $store)
);
return $registry;
});
}
}
Apply the Workflow to a Model
Use the MarkingStore to track state in your Eloquent model:
// app/Models/Order.php
class Order extends Model
{
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): void
{
$this->status = $status;
}
}
Trigger a Transition Use the registry to fetch the workflow for the subject:
use Symfony\Component\Workflow\Registry;
public function submitOrder(Order $order)
{
$workflow = app(Registry::class)->get($order, 'order_workflow');
$workflow->apply($order, 'submit');
$order->save();
event(new OrderSubmitted($order));
}
Check Current State
$marking = $workflow->getMarking($order);
$isPending = $workflow->can($order, 'approve');
$nextSteps = $workflow->getEnabledTransitions($order);
draft → pending → processing → shipped → delivered with conditional transitions (e.g., "approve only if payment is verified").Order model, and trigger transitions via API/controllers.if-else logic with a declarative workflow, reducing bugs and improving maintainability.Model Integration
MethodMarkingStore for Eloquent models (recommended for Laravel):
$store = new MethodMarkingStore(true, 'status'); // uses getStatus()/setStatus()
$workflow = new Workflow($definition, $store);
DoctrineMarkingStore or PropertyMarkingStore.Event-Driven Transitions
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Workflow\Event\TransitionEvent;
use Symfony\Component\Workflow\Event\GuardEvent;
$dispatcher = new EventDispatcher();
$dispatcher->addListener('workflow.order_workflow.transition', function (TransitionEvent $event) {
$order = $event->getSubject();
$transition = $event->getTransition()->getName();
event(new WorkflowTransitioned($order, $transition));
});
$workflow = new Workflow($definition, $store, $dispatcher, 'order_workflow');
workflow.order_workflow.guard.<transition> / workflow.<name>.guard) are your primary extension point for blocking transitions.Dynamic Workflows
$registry->addWorkflow('dynamic_workflow', new Workflow($dynamicDefinition, $store));
Guard Conditions
pending:
transitions:
approve:
to: processing
guard: [is_granted, payment_verified] # or a Closure/service listener
$dispatcher->addListener('workflow.order_workflow.guard.approve', function (GuardEvent $event) {
if (! $event->getSubject()->payment->isVerified()) {
$event->setBlocked(true, 'Payment not verified');
}
});
Traceable Workflows
TraceableWorkflow:
$traceableWorkflow = new TraceableWorkflow($workflow);
$traceableWorkflow->apply($order, 'submit');
$trace = $traceableWorkflow->getTrace();
reset() between long-running iterations (available in current 8.x, including 8.1.x).Visualization
php bin/console workflow:dump --format=mermaid order_workflow
use Symfony\Component\Workflow\Dumper\GraphvizDumper;
use Symfony\Component\Workflow\Dumper\MermaidDumper;
echo (new GraphvizDumper())->dump($workflow->getDefinition());
echo (new MermaidDumper(MermaidDumper::TRANSITION_TYPE, 'state'))->dump($workflow->getDefinition());
Service Container Integration
WorkflowInterface (the interface has no get() method):
$this->app->bind('workflow.order', function ($app) {
return $app[Registry::class]->get(new \App\Models\Order(), 'order_workflow');
});
Registry and resolve per-subject, which is the safe route for anything tenant/config-dependent.Artisan Commands
WorkflowDumpCommand in a Laravel command, or call the dumper directly in a custom php artisan workflow:dump command. Avoid instantiating Symfony’s Console Application inside Laravel; it fights the Laravel kernel.Testing Workflows
Workflow instance built from the actual definition (fast, no DB), and only mock when the workflow is injected:
$workflow = new Workflow($definition, new MethodMarkingStore(true, 'status'));
$this->assertTrue($workflow->can($order, 'submit'));
API Resource Integration
return new JsonResponse([
'status' => $order->status,
'allowed_transitions' => array_map(
fn ($t) => $t->getName(),
$workflow->getEnabledTransitions($order)
),
]);
Queueable Transitions
dispatch(new ApplyWorkflowTransition($order, 'submit'));
public function handle(Registry $registry)
{
$workflow = $registry->get($this->order, 'order_workflow');
$workflow->apply($this->order, $this->transition);
$this->order->save();
}
can() first or add ShouldBeUnique.State Contamination
MethodMarkingStore with explicit getter/setter methods, or upgrade to Symfony 7.4+/8.x (fixed in #62211).Empty String Place Names
"") may fail.Event Dispatching
true as the second argument to Workflow::apply($subject, $transition, true) to force context (fixed in #60194).Mermaid Label Escaping (#) — new in v8.1.8
# (e.g. places/transitions named order#42, step#2) produced invalid Mermaid syntax and broke diagram rendering. Fixed in #66202 ("Escape the hash in Mermaid labels").# in place/transition names, or post-process the dumper output and replace # with #35; before feeding it to Mermaid.TraceableWorkflow Reset
TraceableWorkflow lacked a reset() method in older versions, so traces accumulated across loops/requests.HTML Escaping in Graphviz
GraphvizDumper may escape HTML incorrectly.BackedEnum Support
MethodMarkingStore may crash with BackedEnum properties.v8.1.8), or use PropertyMarkingStore for enum-backed state.Version Drift in a Framework-Agnostic Package
composer require symfony/workflow can pull a different minor than symfony/console/http-kernel already in your lockfile, and the Mermaid/Graphviz dumpers have had several patch-level fixes.^8.1) and bump together. Check composer show symfony/workflow before filing a "diagram is broken" bug — v8.1.8 vs. v8.1.0 matters here.Dump Workflow State
php artisan workflow:dump order_workflow
php artisan workflow:dump order_workflow --format=mermaid
#, quotes, brackets) rather than the workflow definition.Verify the Registry, Not the Interface
$workflow = app(\Symfony\Component\Workflow\Registry::class)->get($order, 'order_workflow');
dump($workflow->getMarking($order)->getPlaces());
dump($workflow->getEnabledTransitions($order));
WorkflowInterface has no get() method — the Registry does. A BadMethodCallException here almost always means a stale copy-paste binding.
Log Every Transition via One Listener
$dispatcher->addListener('workflow.order_workflow.transition', fn ($e) =>
logger()->info('workflow', ['transition' => $e->getTransition()->getName(), 'id' => $e->getSubject()->id])
);
This single hook surfaces "why didn't my guard fire" faster than instrumenting each controller.
Assert Blocked Guards Explicitly
$event = new GuardEvent($order, $marking, $transition);
// assert $event->isBlocked() and inspect getBlockedReasons()
Silent guard failures (a closure returning null instead of false) are the most common "transition does nothing" cause.
Check composer show symfony/workflow First
Several of the pitfalls above are patch-level fixes. Before debugging dumper output or marking-store behaviour, confirm you're on the latest 8.1.x — v8.1.8 currently.
How can I help you explore Laravel packages today?