Workflow
Using the Workflow component inside a Symfony application requires first knowingsome basic theory and concepts about workflows and state machines.Read this article for a quick overview.
Installation
In applications usingSymfony Flex, run this command toinstall the workflow feature before using it:
1
$composer require symfony/workflowConfiguration
To see all configuration options, if you are using the component inside aSymfony project run this command:
1
$php bin/console config:dump-reference framework workflowsCreating a Workflow
A workflow is a process or a lifecycle that your objects go through. Eachstep or stage in the process is called aplace. You also definetransitions,which describe the action needed to get from one place to another.

A set of places and transitions creates adefinition. A workflow needsaDefinition and a way to write the states to the objects (i.e. aninstance of aMarkingStoreInterface.)
Consider the following example for a blog post. A post can have these places:draft,reviewed,rejected,published. You could define the workflow asfollows:
12345678910111213141516171819202122232425262728
# config/packages/workflow.yamlframework:workflows:blog_publishing:type:'workflow'# or 'state_machine'audit_trail:enabled:truemarking_store:type:'method'property:'currentPlace'supports:-App\Entity\BlogPostinitial_marking:draftplaces:# defining places manually is optional-draft-reviewed-rejected-publishedtransitions:to_review:from:draftto:reviewedpublish:from:reviewedto:publishedreject:from:reviewedto:rejected1234567891011121314151617181920212223242526272829303132333435363738394041
<!-- config/packages/workflow.xml --><?xml version="1.0" encoding="UTF-8" ?><containerxmlns="http://symfony.com/schema/dic/services"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xmlns:framework="http://symfony.com/schema/dic/symfony"xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd http://symfony.com/schema/dic/symfony https://symfony.com/schema/dic/symfony/symfony-1.0.xsd"><framework:config><!-- or type="state_machine" --><framework:workflowname="blog_publishing"type="workflow"><framework:audit-trailenabled="true"/><framework:marking-storetype="single_state"><framework:argument>currentPlace</framework:argument></framework:marking-store><framework:support>App\Entity\BlogPost</framework:support><framework:initial-marking>draft</framework:initial-marking><!-- defining places manually is optional --><framework:place>draft</framework:place><framework:place>reviewed</framework:place><framework:place>rejected</framework:place><framework:place>published</framework:place><framework:transitionname="to_review"><framework:from>draft</framework:from><framework:to>reviewed</framework:to></framework:transition><framework:transitionname="publish"><framework:from>reviewed</framework:from><framework:to>published</framework:to></framework:transition><framework:transitionname="reject"><framework:from>reviewed</framework:from><framework:to>rejected</framework:to></framework:transition></framework:workflow></framework:config></container>12345678910111213141516171819202122232425262728293031323334353637
// config/packages/workflow.phpuseApp\Entity\BlogPost;useSymfony\Config\FrameworkConfig;returnstaticfunction(FrameworkConfig$framework):void{$blogPublishing =$framework->workflows()->workflows('blog_publishing');$blogPublishing ->type('workflow')// or 'state_machine' ->supports([BlogPost::class]) ->initialMarking(['draft']);$blogPublishing->auditTrail()->enabled(true);$blogPublishing->markingStore() ->type('method') ->property('currentPlace');// defining places manually is optional$blogPublishing->place()->name('draft');$blogPublishing->place()->name('reviewed');$blogPublishing->place()->name('rejected');$blogPublishing->place()->name('published');$blogPublishing->transition() ->name('to_review') ->from(['draft']) ->to(['reviewed']);$blogPublishing->transition() ->name('publish') ->from(['reviewed']) ->to(['published']);$blogPublishing->transition() ->name('reject') ->from(['reviewed']) ->to(['rejected']);};Tip
If you are creating your first workflows, consider using theworkflow:dumpcommand todebug the workflow contents.
Tip
You can use PHP constants in YAML files via the!php/const notation.E.g. you can use!php/const App\Entity\BlogPost::STATE_DRAFT instead of'draft' or!php/const App\Entity\BlogPost::TRANSITION_TO_REVIEWinstead of'to_review'.
Tip
You can omit theplaces option if your transitions define all the placesthat are used in the workflow. Symfony will automatically extract the placesfrom the transitions.
7.1
The support for omitting theplaces option was introduced inSymfony 7.1.
The configured property will be used via its implemented getter/setter methods by the marking store:
123456789101112131415161718192021222324
// src/Entity/BlogPost.phpnamespaceApp\Entity;classBlogPost{// the configured marking store property must be declaredprivatestring$currentPlace;privatestring$title;privatestring$content;// getter/setter methods must exist for property access by the marking storepublicfunctiongetCurrentPlace():string{return$this->currentPlace; }publicfunctionsetCurrentPlace(string$currentPlace,array$context = []):void{$this->currentPlace =$currentPlace; }// you don't need to set the initial marking in the constructor or any other method;// this is configured in the workflow with the 'initial_marking' option}It is also possible to use public properties for the marking store. The aboveclass would become the following:
12345678910
// src/Entity/BlogPost.phpnamespaceApp\Entity;classBlogPost{// the configured marking store property must be declaredpublicstring$currentPlace;publicstring$title;publicstring$content;}When using public properties, context is not supported. In order to support it,you must declare a setter to write your property:
12345678910111213
// src/Entity/BlogPost.phpnamespaceApp\Entity;classBlogPost{publicstring$currentPlace;// ...publicfunctionsetCurrentPlace(string$currentPlace,array$context = []):void{// assign the property and do something with the context }}Note
The marking store type could be "multiple_state" or "single_state". A singlestate marking store does not support a model being on multiple places at thesame time. This means a "workflow" must use a "multiple_state" marking storeand a "state_machine" must use a "single_state" marking store. Symfonyconfigures the marking store according to the "type" by default, so it'spreferable to not configure it.
A single state marking store uses astring to store the data. A multiplestate marking store uses anarray to store the data. If no state markingstore is defined you have to returnnull in both cases (e.g. the aboveexample should define a return type likeApp\Entity\BlogPost::getCurrentPlace(): ?arrayor likeApp\Entity\BlogPost::getCurrentPlace(): ?string).
Tip
Themarking_store.type (the default value depends on thetype value)andproperty (default value['marking']) attributes of themarking_store option are optional. If omitted, their default values willbe used. It's highly recommended to use the default value.
Tip
Setting theaudit_trail.enabled option totrue makes the applicationgenerate detailed log messages for the workflow activity.
With this workflow namedblog_publishing, you can get help to decidewhat actions are allowed on a blog post:
12345678910111213141516171819202122
useApp\Entity\BlogPost;useSymfony\Component\Workflow\Exception\LogicException;$post =newBlogPost();// you don't need to set the initial marking with code; this is configured// in the workflow with the 'initial_marking' option$workflow =$this->container->get('workflow.blog_publishing');$workflow->can($post,'publish');// False$workflow->can($post,'to_review');// True// Update the currentState on the posttry {$workflow->apply($post,'to_review');}catch (LogicException$exception) {// ...}// See all the available transitions for the post in the current state$transitions =$workflow->getEnabledTransitions($post);// See a specific available transition for the post in the current state$transition =$workflow->getEnabledTransition($post,'publish');Using a multiple state marking store
If you are creating aworkflow,your marking store may need to contain multiple places at the same time. That's why,if you are using Doctrine, the matching column definition should use the typejson:
12345678910111213141516171819
// src/Entity/BlogPost.phpnamespaceApp\Entity;useDoctrine\DBAL\Types\Types;useDoctrine\ORM\MappingasORM;#[ORM\Entity]classBlogPost{#[ORM\Id]#[ORM\GeneratedValue]#[ORM\Column]privateint$id;#[ORM\Column(type: Types::JSON)]privatearray$currentPlaces;// ...}Warning
You should not use the typesimple_array for your marking store. Insidea multiple state marking store, places are stored as keys with a value of one,such as['draft' => 1]. If the marking store contains only one place,this Doctrine type will store its value only as a string, resulting in theloss of the object's current place.
Accessing the Workflow in a Class
Symfony creates a service for each workflow you define. You have two ways ofinjecting each workflow in any service or controller:
(1) Use a specific argument name
Type-hint your constructor/method argument withWorkflowInterface and name theargument using this pattern: "workflow name in camelCase" +Workflow suffix.If it is a state machine type, use theStateMachine suffix.
For example, to inject theblog_publishing workflow defined earlier:
123456789101112131415161718192021
useApp\Entity\BlogPost;useSymfony\Component\Workflow\WorkflowInterface;classMyClass{publicfunction__construct(private WorkflowInterface$blogPublishingWorkflow, ){ }publicfunctiontoReview(BlogPost$post):void{try {// update the currentState on the post$this->blogPublishingWorkflow->apply($post,'to_review'); }catch (LogicException$exception) {// ... }// ... }}(2) Use the#[Target] attribute
Whendealing with multiple implementations of the same typethe#[Target] attribute helps you select which one to inject. Symfony createsa target with the same name as each workflow.
For example, to select theblog_publishing workflow defined earlier:
123456789101112
useSymfony\Component\DependencyInjection\Attribute\Target;useSymfony\Component\Workflow\WorkflowInterface;classMyClass{publicfunction__construct(#[Target('blog_publishing')]private WorkflowInterface$workflow, ){ }// ...}To get the enabled transition of a Workflow, you can usegetEnabledTransition()method.
7.1
ThegetEnabledTransition()method was introduced in Symfony 7.1.
Tip
If you want to retrieve all workflows, for documentation purposes for example,you caninject all serviceswith the following tag:
workflow: all workflows and all state machine;workflow.workflow: all workflows;workflow.state_machine: all state machines.
Note that workflow metadata are attached to tags under themetadata key,giving you more context and information about the workflow at disposal.Learn more abouttag attributes andstoring workflow metadata.
7.1
The attached configuration to the tag was introduced in Symfony 7.1.
Tip
You can find the list of available workflow services with thephp bin/console debug:autowiring workflow command.
Injecting Multiple Workflows
Use theAutowireLocator attribute tolazy-load all workflows and get the one you need:
1234567891011121314151617181920212223
useSymfony\Component\DependencyInjection\Attribute\AutowireLocator;useSymfony\Component\DependencyInjection\ServiceLocator;classMyClass{publicfunction__construct( //'workflow' is the service tag name and injects both workflows and state machines; //'name' tells Symfony to index services using that tag property#[AutowireLocator('workflow','name')]private ServiceLocator$workflows, ){ }publicfunctionsomeMethod():void{// if you use the 'name' tag property to index services (see constructor above),// you can get workflows by their name; otherwise, you must use the full// service name with the 'workflow.' prefix (e.g. 'workflow.user_registration')$workflow =$this->workflows->get('user_registration');// ... }}Tip
You can also inject only workflows or only state machines:
1234567
publicfunction__construct(#[AutowireLocator('workflow.workflow','name')]private ServiceLocator$workflows,#[AutowireLocator('workflow.state_machine','name')]private ServiceLocator$stateMachines,){}Using Events
To make your workflows more flexible, you can construct theWorkflowobject with anEventDispatcher. You can now create event listeners toblock transitions (i.e. depending on the data in the blog post) and doadditional actions when a workflow operation happened (e.g. sendingannouncements).
Each step has three events that are fired in order:
- An event for every workflow;
- An event for the workflow concerned;
- An event for the workflow concerned with the specific transition or place name.
When a state transition is initiated, the events are dispatched in the followingorder:
workflow.guardValidate whether the transition is blocked or not (seeguard events andblocking transitions).
The three events being dispatched are:
workflow.guardworkflow.[workflow name].guardworkflow.[workflow name].guard.[transition name]
workflow.leaveThe subject is about to leave a place.
The three events being dispatched are:
workflow.leaveworkflow.[workflow name].leaveworkflow.[workflow name].leave.[place name]
workflow.transitionThe subject is going through this transition.
The three events being dispatched are:
workflow.transitionworkflow.[workflow name].transitionworkflow.[workflow name].transition.[transition name]
workflow.enterThe subject is about to enter a new place. This event is triggered rightbefore the subject places are updated, which means that the marking of thesubject is not yet updated with the new places.
The three events being dispatched are:
workflow.enterworkflow.[workflow name].enterworkflow.[workflow name].enter.[place name]
workflow.enteredThe subject has entered in the places and the marking is updated.
The three events being dispatched are:
workflow.enteredworkflow.[workflow name].enteredworkflow.[workflow name].entered.[place name]
workflow.completedThe object has completed this transition.
The three events being dispatched are:
workflow.completedworkflow.[workflow name].completedworkflow.[workflow name].completed.[transition name]
workflow.announceTriggered for each transition that now is accessible for the subject.
The three events being dispatched are:
workflow.announceworkflow.[workflow name].announceworkflow.[workflow name].announce.[transition name]
After a transition is applied, the announce event tests for all availabletransitions. That will trigger allguard eventsonce more, which could impact performance if they include intensive CPU ordatabase workloads.
If you don't need the announce event, disable it using the context:
1
$workflow->apply($subject,$transitionName, [Workflow::DISABLE_ANNOUNCE_EVENT =>true]);
Note
The leaving and entering events are triggered even for transitions that stayin the same place.
Note
If you initialize the marking by calling$workflow->getMarking($object);,then theworkflow.[workflow_name].entered.[initial_place_name] event willbe called with the default context (Workflow::DEFAULT_INITIAL_CONTEXT).
Here is an example of how to enable logging for every time a "blog_publishing"workflow leaves a place:
1234567891011121314151617181920212223242526272829303132333435
// src/App/EventSubscriber/WorkflowLoggerSubscriber.phpnamespaceApp\EventSubscriber;usePsr\Log\LoggerInterface;useSymfony\Component\EventDispatcher\EventSubscriberInterface;useSymfony\Component\Workflow\Event\Event;useSymfony\Component\Workflow\Event\LeaveEvent;classWorkflowLoggerSubscriberimplementsEventSubscriberInterface{publicfunction__construct(private LoggerInterface$logger, ){ }publicfunctiononLeave(Event$event):void{$this->logger->alert(sprintf('Blog post (id: "%s") performed transition "%s" from "%s" to "%s"',$event->getSubject()->getId(),$event->getTransition()->getName(),implode(', ',array_keys($event->getMarking()->getPlaces())),implode(', ',$event->getTransition()->getTos()) )); }publicstaticfunctiongetSubscribedEvents():array{return [ LeaveEvent::getName('blog_publishing') =>'onLeave',// if you prefer, you can write the event name manually like this:// 'workflow.blog_publishing.leave' => 'onLeave', ]; }}Tip
All built-in workflow events define thegetName(?string $workflowName, ?string $transitionOrPlaceName)method to build the full event name without having to deal with strings.You can also use this method in your custom events via theEventNameTrait.
7.1
ThegetName() method was introduced in Symfony 7.1.
If some listeners update the context during a transition, you can retrieveit via the marking:
1234
$marking =$workflow->apply($post,'to_review');// contains the new value$marking->getContext();It is also possible to listen to these events by declaring event listenerswith the following attributes:
- AsAnnounceListener
- AsCompletedListener
- AsEnterListener
- AsEnteredListener
- AsGuardListener
- AsLeaveListener
- AsTransitionListener
These attributes do work like theAsEventListenerattributes:
12345678910
classArticleWorkflowEventListener{#[AsTransitionListener(workflow:'my-workflow',transition:'published')]publicfunctiononPublishedTransition(TransitionEvent$event):void{// ... }// ...}You may refer to the documentation aboutdefining event listeners with PHP attributesfor further use.
Guard Events
There are special types of events called "Guard events". Their event listenersare invoked every time a call toWorkflow::can(),Workflow::apply() orWorkflow::getEnabledTransitions() is executed. With the guard events you mayadd custom logic to decide which transitions should be blocked or not. Here is alist of the guard event names.
workflow.guardworkflow.[workflow name].guardworkflow.[workflow name].guard.[transition name]
This example stops any blog post being transitioned to "reviewed" if it ismissing a title:
123456789101112131415161718192021222324252627
// src/App/EventSubscriber/BlogPostReviewSubscriber.phpnamespaceApp\EventSubscriber;useApp\Entity\BlogPost;useSymfony\Component\EventDispatcher\EventSubscriberInterface;useSymfony\Component\Workflow\Event\GuardEvent;classBlogPostReviewSubscriberimplementsEventSubscriberInterface{publicfunctionguardReview(GuardEvent$event):void{/**@var BlogPost $post */$post =$event->getSubject();$title =$post->title;if (empty($title)) {$event->setBlocked(true,'This blog post cannot be marked as reviewed because it has no title.'); } }publicstaticfunctiongetSubscribedEvents():array{return ['workflow.blog_publishing.guard.to_review' => ['guardReview'], ]; }}Choosing which Events to Dispatch
If you prefer to control which events are fired when performing each transition,use theevents_to_dispatch configuration option. This option does not applytoGuard events, which are always fired:
1234567891011
# config/packages/workflow.yamlframework:workflows:blog_publishing:# you can pass one or more event namesevents_to_dispatch:['workflow.leave','workflow.completed']# pass an empty array to not dispatch any eventevents_to_dispatch:[]# ...123456789101112131415161718192021
<!-- config/packages/workflow.xml --><?xml version="1.0" encoding="UTF-8" ?><containerxmlns="http://symfony.com/schema/dic/services"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xmlns:framework="http://symfony.com/schema/dic/symfony"xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd http://symfony.com/schema/dic/symfony https://symfony.com/schema/dic/symfony/symfony-1.0.xsd"><framework:config><framework:workflowname="blog_publishing"><!-- you can pass one or more event names --><framework:event-to-dispatch>workflow.leave</framework:event-to-dispatch><framework:event-to-dispatch>workflow.completed</framework:event-to-dispatch><!-- pass an empty array to not dispatch any event --><framework:event-to-dispatch></framework:event-to-dispatch><!-- ... --></framework:workflow></framework:config></container>1234567891011121314151617181920
// config/packages/workflow.phpuseSymfony\Config\FrameworkConfig;returnstaticfunction(FrameworkConfig$framework):void{// ...$blogPublishing =$framework->workflows()->workflows('blog_publishing');// ...// you can pass one or more event names$blogPublishing->eventsToDispatch(['workflow.leave','workflow.completed', ]);// pass an empty array to not dispatch any event$blogPublishing->eventsToDispatch([]);// ...};You can also disable a specific event from being fired when applying a transition:
123456789101112131415
useApp\Entity\BlogPost;useSymfony\Component\Workflow\Exception\LogicException;$post =newBlogPost();$workflow =$this->container->get('workflow.blog_publishing');try {$workflow->apply($post,'to_review', [ Workflow::DISABLE_ANNOUNCE_EVENT =>true, Workflow::DISABLE_LEAVE_EVENT =>true, ]);}catch (LogicException$exception) {// ...}Disabling an event for a specific transition will take precedence over anyevents specified in the workflow configuration. In the above example theworkflow.leave event will not be fired, even if it has been specified as anevent to be dispatched for all transitions in the workflow configuration.
These are all the available constants:
Workflow::DISABLE_LEAVE_EVENTWorkflow::DISABLE_TRANSITION_EVENTWorkflow::DISABLE_ENTER_EVENTWorkflow::DISABLE_ENTERED_EVENTWorkflow::DISABLE_COMPLETED_EVENT
Event Methods
Each workflow event is an instance ofEvent.This means that each event has access to the following information:
- getMarking()
- Returns theMarking of the workflow.
- getSubject()
- Returns the object that dispatches the event.
- getTransition()
- Returns theTransition that dispatches the event.
- getWorkflowName()
- Returns a string with the name of the workflow that triggered the event.
- getMetadata()
- Returns a metadata.
For Guard Events, there is an extendedGuardEvent class.This class has these additional methods:
- isBlocked()
- Returns if transition is blocked.
- setBlocked()
- Sets the blocked value.
- getTransitionBlockerList()
- Returns the eventTransitionBlockerList.Seeblocking transitions.
- addTransitionBlocker()
- Add aTransitionBlocker instance.
Blocking Transitions
The execution of the workflow can be controlled by calling custom logic todecide if the current transition is blocked or allowed before applying it. Thisfeature is provided by "guards", which can be used in two ways.
First, you can listen tothe guard events.Alternatively, you can define aguard configuration option for thetransition. The value of this option is any valid expression created with theExpressionLanguage component:
123456789101112131415161718192021
# config/packages/workflow.yamlframework:workflows:blog_publishing:# previous configurationtransitions:to_review:# the transition is allowed only if the current user has the ROLE_REVIEWER role.guard:"is_granted('ROLE_REVIEWER')"from:draftto:reviewedpublish:# or "is_remember_me", "is_fully_authenticated", "is_granted", "is_valid"guard:"is_authenticated"from:reviewedto:publishedreject:# or any valid expression language with "subject" referring to the supported objectguard:"is_granted('ROLE_ADMIN') and subject.isRejectable()"from:reviewedto:rejected12345678910111213141516171819202122232425262728293031323334353637383940
<!-- config/packages/workflow.xml --><?xml version="1.0" encoding="UTF-8" ?><containerxmlns="http://symfony.com/schema/dic/services"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xmlns:framework="http://symfony.com/schema/dic/symfony"xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd http://symfony.com/schema/dic/symfony https://symfony.com/schema/dic/symfony/symfony-1.0.xsd"><framework:config><framework:workflowname="blog_publishing"type="workflow"><!-- ... previous configuration --><framework:transitionname="to_review"><!-- the transition is allowed only if the current user has the ROLE_REVIEWER role. --><framework:guard>is_granted("ROLE_REVIEWER")</framework:guard><framework:from>draft</framework:from><framework:to>reviewed</framework:to></framework:transition><framework:transitionname="publish"><!-- or "is_remember_me", "is_fully_authenticated", "is_granted" --><framework:guard>is_authenticated</framework:guard><framework:from>reviewed</framework:from><framework:to>published</framework:to></framework:transition><framework:transitionname="reject"><!-- or any valid expression language with "subject" referring to the post --><framework:guard>is_granted("ROLE_ADMIN") and subject.isStatusReviewed()</framework:guard><framework:from>reviewed</framework:from><framework:to>rejected</framework:to></framework:transition></framework:workflow></framework:config></container>12345678910111213141516171819202122232425262728
// config/packages/workflow.phpuseSymfony\Config\FrameworkConfig;returnstaticfunction(FrameworkConfig$framework):void{$blogPublishing =$framework->workflows()->workflows('blog_publishing');// ... previous configuration$blogPublishing->transition() ->name('to_review')// the transition is allowed only if the current user has the ROLE_REVIEWER role. ->guard('is_granted("ROLE_REVIEWER")') ->from(['draft']) ->to(['reviewed']);$blogPublishing->transition() ->name('publish')// or "is_remember_me", "is_fully_authenticated", "is_granted" ->guard('is_authenticated') ->from(['reviewed']) ->to(['published']);$blogPublishing->transition() ->name('reject')// or any valid expression language with "subject" referring to the post ->guard('is_granted("ROLE_ADMIN") and subject.isStatusReviewed()') ->from(['reviewed']) ->to(['rejected']);};You can also use transition blockers to block and return a user-friendly errormessage when you stop a transition from happening.In the example we get this message from theEvent's metadata, giving you acentral place to manage the text.
This example has been simplified; in production you may prefer to use theTranslation component to manage messages in oneplace:
12345678910111213141516171819202122232425262728293031
// src/App/EventSubscriber/BlogPostPublishSubscriber.phpnamespaceApp\EventSubscriber;useSymfony\Component\EventDispatcher\EventSubscriberInterface;useSymfony\Component\Workflow\Event\GuardEvent;useSymfony\Component\Workflow\TransitionBlocker;classBlogPostPublishSubscriberimplementsEventSubscriberInterface{publicfunctionguardPublish(GuardEvent$event):void{$eventTransition =$event->getTransition();$hourLimit =$event->getMetadata('hour_limit',$eventTransition);if (date('H') <=$hourLimit) {return; }// Block the transition "publish" if it is more than 8 PM// with the message for end user$explanation =$event->getMetadata('explanation',$eventTransition);$event->addTransitionBlocker(newTransitionBlocker($explanation ,'0')); }publicstaticfunctiongetSubscribedEvents():array{return ['workflow.blog_publishing.guard.publish' => ['guardPublish'], ]; }}Creating Your Own Marking Store
You may need to implement your own store to execute some additional logicwhen the marking is updated. For example, you may have some specific needsto store the marking on certain workflows. To do this, you need to implementtheMarkingStoreInterface:
123456789101112131415161718192021222324
namespaceApp\Workflow\MarkingStore;useSymfony\Component\Workflow\Marking;useSymfony\Component\Workflow\MarkingStore\MarkingStoreInterface;finalclassBlogPostMarkingStoreimplementsMarkingStoreInterface{/** *@param BlogPost $subject */publicfunctiongetMarking(object$subject):Marking{returnnewMarking([$subject->getCurrentPlace() =>1]); }/** *@param BlogPost $subject */publicfunctionsetMarking(object$subject, Marking$marking,array$context = []):void{$marking =key($marking->getPlaces());$subject->setCurrentPlace($marking); }}Once your marking store is implemented, you can configure your workflow to useit:
1234567
# config/packages/workflow.yamlframework:workflows:blog_publishing:# ...marking_store:service:'App\Workflow\MarkingStore\BlogPostMarkingStore'123456789101112131415
<!-- config/packages/workflow.xml --><?xml version="1.0" encoding="UTF-8" ?><containerxmlns="http://symfony.com/schema/dic/services"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xmlns:framework="http://symfony.com/schema/dic/symfony"xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd http://symfony.com/schema/dic/symfony https://symfony.com/schema/dic/symfony/symfony-1.0.xsd"><framework:config><framework:workflowname="blog_publishing"><!-- ... --><framework:marking-storeservice="App\Workflow\MarkingStore\BlogPostMarkingStore"/></framework:workflow></framework:config></container>12345678910111213
// config/packages/workflow.phpuseApp\Workflow\MarkingStore\ReflectionMarkingStore;useSymfony\Config\FrameworkConfig;returnstaticfunction(FrameworkConfig$framework):void{// ...$blogPublishing =$framework->workflows()->workflows('blog_publishing');// ...$blogPublishing->markingStore() ->service(BlogPostMarkingStore::class);};Usage in Twig
Symfony defines several Twig functions to manage workflows and reduce the needof domain logic in your templates:
workflow_can()- Returns
trueif the given object can make the given transition. workflow_transitions()- Returns an array with all the transitions enabled for the given object.
workflow_transition()- Returns a specific transition enabled for the given object and transition name.
workflow_marked_places()- Returns an array with the place names of the given marking.
workflow_has_marked_place()- Returns
trueif the marking of the given object has the given state. workflow_transition_blockers()- ReturnsTransitionBlockerList for the given transition.
The following example shows these functions in action:
1234567891011121314151617181920212223242526272829303132
<h3>Actions on Blog Post</h3>{%if workflow_can(post, 'publish') %}<ahref="...">Publish</a>{%endif %}{%if workflow_can(post, 'to_review') %}<ahref="...">Submit to review</a>{%endif %}{%if workflow_can(post, 'reject') %}<ahref="...">Reject</a>{%endif %}{# Or loop through the enabled transitions #}{%for transition in workflow_transitions(post) %}<ahref="...">{{ transition.name }}</a>{%else %} No actions available.{%endfor %}{# Check if the object is in some specific place #}{%if workflow_has_marked_place(post, 'reviewed') %}<p>This post is ready for review.</p>{%endif %}{# Check if some place has been marked on the object #}{%if 'reviewed' in workflow_marked_places(post) %}<spanclass="label">Reviewed</span>{%endif %}{# Loop through the transition blockers #}{%for blocker in workflow_transition_blockers(post, 'publish') %}<spanclass="error">{{ blocker.message }}</span>{%endfor %}Storing Metadata
In case you need it, you can store arbitrary metadata in workflows, theirplaces, and their transitions using themetadata option. This metadata canbe only the title of the workflow or very complex objects:
123456789101112131415161718192021222324
# config/packages/workflow.yamlframework:workflows:blog_publishing:metadata:title:'Blog Publishing Workflow'# ...places:draft:metadata:max_num_of_words:500# ...transitions:to_review:from:draftto:reviewmetadata:priority:0.5publish:from:reviewedto:publishedmetadata:hour_limit:20explanation:'You can not publish after 8 PM.'1234567891011121314151617181920212223242526272829303132333435363738
<!-- config/packages/workflow.xml --><?xml version="1.0" encoding="UTF-8" ?><containerxmlns="http://symfony.com/schema/dic/services"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xmlns:framework="http://symfony.com/schema/dic/symfony"xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd http://symfony.com/schema/dic/symfony https://symfony.com/schema/dic/symfony/symfony-1.0.xsd"><framework:config><framework:workflowname="blog_publishing"><framework:metadata><framework:title>Blog Publishing Workflow</framework:title></framework:metadata><!-- ... --><framework:placename="draft"><framework:metadata><framework:max-num-of-words>500</framework:max-num-of-words></framework:metadata></framework:place><!-- ... --><framework:transitionname="to_review"><framework:from>draft</framework:from><framework:to>review</framework:to><framework:metadata><framework:priority>0.5</framework:priority></framework:metadata></framework:transition><framework:transitionname="publish"><framework:from>reviewed</framework:from><framework:to>published</framework:to><framework:metadata><framework:hour_limit>20</framework:hour_limit><framework:explanation>You can not publish after 8 PM.</framework:explanation></framework:metadata></framework:transition></framework:workflow></framework:config></container>1234567891011121314151617181920212223242526272829303132333435363738
// config/packages/workflow.phpuseSymfony\Config\FrameworkConfig;returnstaticfunction(FrameworkConfig$framework):void{$blogPublishing =$framework->workflows()->workflows('blog_publishing');// ... previous configuration$blogPublishing->metadata(['title' =>'Blog Publishing Workflow' ]);// ...$blogPublishing->place() ->name('draft') ->metadata(['max_num_of_words' =>500, ]);// ...$blogPublishing->transition() ->name('to_review') ->from(['draft']) ->to(['reviewed']) ->metadata(['priority' =>0.5, ]);$blogPublishing->transition() ->name('publish') ->from(['reviewed']) ->to(['published']) ->metadata(['hour_limit' =>20,'explanation' =>'You can not publish after 8 PM.', ]);};Then you can access this metadata in your controller as follows:
12345678910111213141516171819202122232425
// src/App/Controller/BlogPostController.phpuseApp\Entity\BlogPost;useSymfony\Component\Workflow\WorkflowInterface;// ...publicfunctionmyAction(WorkflowInterface$blogPublishingWorkflow, BlogPost$post):Response{$title =$blogPublishingWorkflow ->getMetadataStore() ->getWorkflowMetadata()['title'] ??'Default title' ;$maxNumOfWords =$blogPublishingWorkflow ->getMetadataStore() ->getPlaceMetadata('draft')['max_num_of_words'] ??500 ;$aTransition =$blogPublishingWorkflow->getDefinition()->getTransitions()[0];$priority =$blogPublishingWorkflow ->getMetadataStore() ->getTransitionMetadata($aTransition)['priority'] ??0 ;// ...}There is agetMetadata() method that works with all kinds of metadata:
12345678
// get "workflow metadata" passing the metadata key as argument$title =$workflow->getMetadataStore()->getMetadata('title');// get "place metadata" passing the metadata key as the first argument and the place name as the second argument$maxNumOfWords =$workflow->getMetadataStore()->getMetadata('max_num_of_words','draft');// get "transition metadata" passing the metadata key as the first argument and a Transition object as the second argument$priority =$workflow->getMetadataStore()->getMetadata('priority',$aTransition);In aflash message in your controller:
12345
// $transition = ...; (an instance of Transition)// $workflow is an injected Workflow instance$title =$workflow->getMetadataStore()->getMetadata('title',$transition);$this->addFlash('info',"You have successfully applied the transition with title: '$title'");Metadata can also be accessed in a Listener, from theEvent object.
In Twig templates, metadata is available via theworkflow_metadata() function:
123456789101112131415161718192021222324252627282930313233343536
<h2>Metadata of Blog Post</h2><p><strong>Workflow</strong>:<br><code>{{ workflow_metadata(blog_post, 'title') }}</code></p><p><strong>Current place(s)</strong><ul>{%for place in workflow_marked_places(blog_post) %}<li>{{ place }}:<code>{{ workflow_metadata(blog_post, 'max_num_of_words', place) ?: 'Unlimited'}}</code></li>{%endfor %}</ul></p><p><strong>Enabled transition(s)</strong><ul>{%for transition in workflow_transitions(blog_post) %}<li>{{ transition.name }}:<code>{{ workflow_metadata(blog_post, 'priority', transition) ?: 0 }}</code></li>{%endfor %}</ul></p><p><strong>to_review Priority</strong><ul><li> to_review:<code>{{ workflow_metadata(blog_post, 'priority', workflow_transition(blog_post, 'to_review')) }}</code></li></ul></p>Validating Workflow Definitions
Symfony allows you to validate workflow definitions using your own custom logic.To do so, create a class that implements theDefinitionValidatorInterface:
1234567891011121314151617
namespaceApp\Workflow\Validator;useSymfony\Component\Workflow\Definition;useSymfony\Component\Workflow\Exception\InvalidDefinitionException;useSymfony\Component\Workflow\Validator\DefinitionValidatorInterface;finalclassBlogPublishingValidatorimplementsDefinitionValidatorInterface{publicfunctionvalidate(Definition$definition,string$name):void{if (!$definition->getMetadataStore()->getMetadata('title')) {thrownewInvalidDefinitionException(sprintf('The workflow metadata title is missing in Workflow "%s".',$name)); }// ... }}After implementing your validator, configure your workflow to use it:
12345678
# config/packages/workflow.yamlframework:workflows:blog_publishing:# ...definition_validators:-App\Workflow\Validator\BlogPublishingValidator123456789101112131415
<!-- config/packages/workflow.xml --><?xml version="1.0" encoding="UTF-8" ?><containerxmlns="http://symfony.com/schema/dic/services"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xmlns:framework="http://symfony.com/schema/dic/symfony"xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd http://symfony.com/schema/dic/symfony https://symfony.com/schema/dic/symfony/symfony-1.0.xsd"><framework:config><framework:workflowname="blog_publishing"><!-- ... --><framework:definition-validators>App\Workflow\Validator\BlogPublishingValidator</framework:definition-validators></framework:workflow></framework:config></container>12345678910111213
// config/packages/workflow.phpuseSymfony\Config\FrameworkConfig;returnstaticfunction(FrameworkConfig$framework):void{$blogPublishing =$framework->workflows()->workflows('blog_publishing');// ...$blogPublishing->definitionValidators([ App\Workflow\Validator\BlogPublishingValidator::class ]);// ...};TheBlogPublishingValidator will be executed during container compilationto validate the workflow definition.
7.3
Support for workflow definition validators was introduced in Symfony 7.3.

