├── .gitattributes ├── .gitignore ├── .travis.yml ├── README.md ├── composer.json └── src ├── Context.php ├── ContextInterface.php ├── DefinitionValidator.php ├── Exception ├── InvalidHistoryGuardConditionException.php ├── InvalidRunnableExpressionException.php ├── InvalidWorkflowContextException.php └── InvalidWorkflowDefinitionException.php ├── GraphvizWorkflowRenderer.php ├── Guard ├── HistoryGuard.php ├── StatusHistoryAware.php ├── TimerGuard.php └── VarGuard.php ├── Handler ├── ContextHandler.php └── ContextHandlerInterface.php ├── Workflow.php ├── WorkflowEvent.php ├── WorkflowEvents.php └── WorkflowExceptionEvent.php /.gitattributes: -------------------------------------------------------------------------------- 1 | /tests export-ignore 2 | /phpunit.xml.dist export-ignore 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /bin/ 3 | .idea 4 | composer.lock 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | matrix: 4 | include: 5 | - php: 5.5 6 | - php: 5.6 7 | - php: hhvm 8 | - php: nightly 9 | 10 | before_script: 11 | - travis_retry composer self-update 12 | - travis_retry composer install --no-interaction --prefer-source --dev 13 | 14 | script: 15 | - bin/phpunit --verbose --coverage-text 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | BalnoWorkflow 2 | ============= 3 | 4 | [![Build Status](https://travis-ci.org/onlab/BalnoWorkflow.svg?branch=master)](https://travis-ci.org/onlab/BalnoWorkflow) 5 | 6 | 7 | BalnoWorkflow is a workflow engine built for **PHP 5.5+** based on some other Workflows and State Machines that does 8 | not have all features in the same lib. 9 | 10 | This workflow gives to you the following feature: 11 | 12 | - nice syntax to setup the workflow based on SCXML (http://www.w3.org/TR/scxml) structure. 13 | - easy setup for parallel workflows (fork/merge). 14 | - paused workflow can be resumed by a event trigger. 15 | - no event-based transitions run automatically when guard condition (if was set) is satisfied. 16 | - guards and actions configuration as service using Pimple as DI container. 17 | - events available to lock control, log or anything else you want to implement with event listeners. 18 | 19 | Workflow Events to Subscribe 20 | ---------------------------- 21 | 22 | You can check the events available in the *interface* `WorkflowEvents`: 23 | 24 | - **begin_execution**: triggered when the workflow execution will start. 25 | - **end_execution**: triggered when the workflow execution is done (paused or finished). 26 | - **start_transition**: before onExit actions when changing the workflow state. 27 | - **state_changed**: between onExit and onEntry actions just after setting the current state on context. 28 | - **end_transition**: after onEntry actions when changed the workflow current state. 29 | 30 | Basic Definition Sample 31 | ----------------------- 32 | 33 | Always the first state defined will be set as initial state in the case below, the 'state_1' state is set as 34 | initial state. 35 | 36 | ```php 37 | use BalnoWorkflow\DefinitionsContainer; 38 | 39 | $definitionsContainer = new DefinitionsContainer(); 40 | $definitionsContainer->addDefinition('sample_workflow', [ 41 | 'state_1' => [ 42 | targets => [ 43 | 'state_2' => null, 44 | ], 45 | onExit => [ 46 | [ action => 'pimple_service:method1' ], 47 | ], 48 | ], 49 | 'state_2' => [ 50 | targets => [ 51 | 'state_3' => [ event => 'some_event' ], 52 | 'state_5' => [ guard => 'balno.workflow.guard.timer:hasTimedOut("30m")' ], 53 | ], 54 | onEntry => [ 55 | [ action => 'pimple_service1:method' ], 56 | [ action => 'pimple_service2:method' ], 57 | ], 58 | ], 59 | 'state_3' => [ 60 | targets => [ 61 | 'state_4' => null, 62 | ], 63 | parallel => [ 64 | 'forked_workflow1', 65 | 'forked_workflow2', 66 | ], 67 | ], 68 | 'state_4' => [ 69 | onEntry => [ 70 | [ action => 'pimple_service:method2("param")' ], 71 | ], 72 | ], 73 | 'state_5' => null 74 | ]); 75 | $definitionsContainer->addDefinition('forked_workflow1', [ ... ]); 76 | $definitionsContainer->addDefinition('forked_workflow2', [ ... ]); 77 | ``` 78 | 79 | Given a new `Context` to execute... 80 | 81 | ```php 82 | use BalnoWorkflow\Workflow; 83 | use BalnoWorkflow\Context; 84 | 85 | $context = new Context(); 86 | 87 | $workflow = new Workflow(...); 88 | $workflow->execute($context); 89 | ``` 90 | 91 | ... this workflow will execute the below history: 92 | 93 | > **trigger** `begin_execution` listeners 94 | > 95 | >> **IMPORTANT:** initial state will not trigger onEntry actions 96 | > 97 | > ... check for default (no event set) transitions (found transition without guard to state_2) 98 | > 99 | > **trigger** `begin_transition` listeners 100 | > 101 | > **execute** state_1 `onExit` actions 102 | > 103 | > **trigger** `state_changed` listeners 104 | > 105 | > **execute** state_2 `onEntry` actions 106 | > 107 | > **trigger** `end_transition` listeners 108 | > 109 | > ... check for default transitions (found one with guard) 110 | > 111 | > **execute** guard `balno.workflow.guard.timer:hasTimedOut("30m")` that returns `false` 112 | > 113 | > **trigger** `end_execution` listeners 114 | 115 | Now the state is in a paused state. To move forward the workflow must be executed after 30m (to satisfy the configured 116 | guard timeout) or be executed with an event `some_event`. 117 | 118 | To trigger the event you must run the code below (imagine that you is resuming the workflow with a persisted context): 119 | 120 | ``` 121 | $context = $myContextService->getSubjectWorkflowContext($subject); 122 | $workflow->execute($context, 'some_event'); 123 | ``` 124 | 125 | .. or if you just want to resume the workflow to reach the timeout condition: 126 | ``` 127 | $context = $myContextService->getSubjectWorkflowContext($subject); 128 | $workflow->execute($context); 129 | ``` 130 | 131 | Actions 132 | ------- 133 | 134 | Every action or guard are executed by the workflow passing the `Context` object as first parameter followed by the 135 | parameters configured on the action or guard. 136 | 137 | Given a guard `guard.example:someGuardCondition("test1", 2)` the method must be something like this: 138 | 139 | ``` 140 | class GuardExample 141 | { 142 | public function someGuardCondition(ContextInterface $context, $parameter1, $parameter2) 143 | { 144 | ... 145 | } 146 | } 147 | ``` 148 | 149 | Parallel Execution 150 | ------------------ 151 | 152 | Parallel execution is a special state that can't forward to an other state (even by an event) until all parallel 153 | workflows are finished. 154 | 155 | From the [Basic Definition Sample]: 156 | ``` 157 | state_3 158 | | 159 | |------------v--------------------v 160 | | | | 161 | | forked_workflow1 forked_workflow2 162 | | | | 163 | |------------^--------------------^ 164 | | 165 | state_4 166 | ``` 167 | 168 | Since PHP unfortunately does not work with threads out-of-the-box the BalnoWorkflow will execute first the 169 | `forked_workflow1` then `forked_workflow2`. So I recommend to place the fast process first then the slowest. 170 | 171 | But imagine you need to send an e-mail to your client to confirm his e-mail (you're using a slow SMTP server) 172 | on the `forked_workflow2` and the `forked_workflow1` will prepare an order to the product factory. Sending an 173 | e-mail to the client will be better than creating an order to the factory. So, in this case, to satisfy the customer, 174 | will be better to execute the slowest workflow first. 175 | 176 | If you really need to execute in parallel you may setup a initial state without a default transition to pause one or 177 | both workflows then run each workflow in kind of worker. 178 | 179 | **IMPORTANT**: the workflow will automatically try to resume the parent context when you resume a child workflow 180 | and it finishes. So, if you're really trying to parallelize this sub workflows ensure that your locking system is 181 | compatible with this scenario. 182 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "onlab/balno-workflow", 3 | "description": "A PHP 5.5+ Workflow with parallel execution", 4 | "require": { 5 | "php": ">=5.5", 6 | "symfony/event-dispatcher": "^2.6" 7 | }, 8 | "require-dev": { 9 | "phpunit/phpunit": "~4.6" 10 | }, 11 | "autoload": { 12 | "psr-4": { 13 | "BalnoWorkflow\\": "src/" 14 | } 15 | }, 16 | "autoload-dev": { 17 | "psr-4": { 18 | "BalnoWorkflow\\TestResource\\": "tests/resource/", 19 | "BalnoWorkflow\\FunctionalTest\\": "tests/functional/", 20 | "BalnoWorkflow\\UnitTest\\": "tests/unit/" 21 | } 22 | }, 23 | "config": { 24 | "bin-dir": "bin" 25 | }, 26 | "authors": [ 27 | { 28 | "name": "Marcus Fernandez", 29 | "email": "mfernandez@onlab.org" 30 | } 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /src/Context.php: -------------------------------------------------------------------------------- 1 | workflowName = $workflowName; 54 | $this->parentContext = $parentContext; 55 | } 56 | 57 | /** 58 | * @return string 59 | */ 60 | public function getWorkflowName() 61 | { 62 | return $this->workflowName; 63 | } 64 | 65 | /** 66 | * @return ContextInterface 67 | */ 68 | public function getParentContext() 69 | { 70 | return $this->parentContext; 71 | } 72 | 73 | /** 74 | * @param ContextInterface $childContext 75 | */ 76 | public function addChildContext(ContextInterface $childContext) 77 | { 78 | $this->childrenContexts[] = $childContext; 79 | } 80 | 81 | /** 82 | * @return ContextInterface[] 83 | */ 84 | public function getChildrenContexts() 85 | { 86 | return $this->childrenContexts; 87 | } 88 | 89 | /** 90 | * @return ContextInterface[] 91 | */ 92 | public function getActiveChildrenContexts() 93 | { 94 | $activeChildrenContexts = []; 95 | 96 | foreach ($this->childrenContexts as $childContext) { 97 | if (!$childContext->hasFinished()) { 98 | $activeChildrenContexts[] = $childContext; 99 | } 100 | } 101 | 102 | return $activeChildrenContexts; 103 | } 104 | 105 | /** 106 | * @return bool 107 | */ 108 | public function hasActiveChildrenContexts() 109 | { 110 | return !empty($this->getActiveChildrenContexts()); 111 | } 112 | 113 | /** 114 | * @return string 115 | */ 116 | public function getCurrentState() 117 | { 118 | return $this->currentState; 119 | } 120 | 121 | /** 122 | * @param string $state 123 | */ 124 | public function setCurrentState($state) 125 | { 126 | $this->lastStateChangedAt = new \DateTime(); 127 | $this->stateHistory[] = $state; 128 | $this->currentState = $state; 129 | $this->childrenContexts = []; 130 | } 131 | 132 | /** 133 | * @return \DateTime 134 | */ 135 | public function getLastStateChangedAt() 136 | { 137 | return $this->lastStateChangedAt; 138 | } 139 | 140 | /** 141 | * @param string $variableName 142 | * @return string 143 | */ 144 | public function getVariable($variableName) 145 | { 146 | return isset($this->variables[$variableName]) ? $this->variables[$variableName] : null; 147 | } 148 | 149 | /** 150 | * @param string $variableName 151 | * @param string $content 152 | */ 153 | public function setVariable($variableName, $content) 154 | { 155 | $this->variables[$variableName] = $content; 156 | } 157 | 158 | /** 159 | * @param string $variableName 160 | */ 161 | public function unsetVariable($variableName) 162 | { 163 | if (isset($this->variables[$variableName])) { 164 | unset($this->variables[$variableName]); 165 | } 166 | } 167 | 168 | /** 169 | * @return string[] 170 | */ 171 | public function getStateHistory() 172 | { 173 | return $this->stateHistory; 174 | } 175 | 176 | /** 177 | * @return bool 178 | */ 179 | public function hasFinished() 180 | { 181 | return $this->hasFinished; 182 | } 183 | 184 | public function finish() 185 | { 186 | $this->hasFinished = true; 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/ContextInterface.php: -------------------------------------------------------------------------------- 1 | $stateProperties) { 10 | if (is_numeric($state)) { 11 | throw new \InvalidWorkflowDefinition(sprintf('State name must have a representative name. %s given', $state)); 12 | } 13 | 14 | if (isset($stateProperties['targets'])) { 15 | foreach ($stateProperties['targets'] as $targetState => $targetProperties) { 16 | if (!isset($definition[$targetState])) { 17 | throw new \InvalidWorkflowDefinition(sprintf('Invalid target state "%s"', $targetState)); 18 | } 19 | } 20 | } 21 | 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Exception/InvalidHistoryGuardConditionException.php: -------------------------------------------------------------------------------- 1 | message = 'The given condition "' . $condition . '" is not available for history guards.'; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Exception/InvalidRunnableExpressionException.php: -------------------------------------------------------------------------------- 1 | message = 'The provided expression "' . $expression . '" is not valid.'; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Exception/InvalidWorkflowContextException.php: -------------------------------------------------------------------------------- 1 | message = 'Given state machine context is in a unhandleable state'; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Exception/InvalidWorkflowDefinitionException.php: -------------------------------------------------------------------------------- 1 | definitions = $definitions; 24 | $this->graphvizPath = $graphvizPath; 25 | } 26 | 27 | /** 28 | * @param string $workflowName 29 | */ 30 | public function renderWorkflowToStream($workflowName) 31 | { 32 | $this->runGraphviz($this->getWorkflowDotRepresentation($workflowName)); 33 | } 34 | 35 | /** 36 | * @param $workflowName 37 | * @return string 38 | */ 39 | public function getWorkflowDotRepresentation($workflowName) 40 | { 41 | reset($this->definitions[$workflowName]); 42 | $digraph = 'digraph G { compound=true;'. $this->processWorkflowConnections($workflowName) . '}'; 43 | 44 | return $digraph; 45 | } 46 | 47 | /** 48 | * @param string $workflowName 49 | * @return string 50 | */ 51 | protected function processWorkflowConnections($workflowName) 52 | { 53 | $digraph = $workflowName . '_' . key($this->definitions[$workflowName]) . ' [shape=box];'; 54 | 55 | foreach ($this->definitions[$workflowName] as $state => $stateProperties) { 56 | $workflowStateName = $workflowName . '_' . $state; 57 | 58 | if (isset($stateProperties['parallel'])) { 59 | $forkName = $workflowStateName . '_fork'; 60 | $mergeName = $workflowStateName . '_merge'; 61 | 62 | $digraph .= sprintf('%s -> %s;', $workflowStateName, $forkName); 63 | $digraph .= $forkName . ' [shape=point];'; 64 | $digraph .= $mergeName . ' [shape=point];'; 65 | 66 | foreach ($stateProperties['parallel'] as $subWorkflowName) { 67 | reset($this->definitions[$subWorkflowName]); 68 | $firstNodeSubWorkflow = $subWorkflowName . '_' . key($this->definitions[$subWorkflowName]); 69 | $endDummyNodeSubWorkflow = $subWorkflowName . '_INVISIBLE_NODE'; 70 | 71 | $digraph .= sprintf('subgraph cluster_%s { color=black; label="%1$s";', $subWorkflowName); 72 | $digraph .= $this->processWorkflowConnections($subWorkflowName); 73 | $digraph .= $endDummyNodeSubWorkflow . '[shape=point style=invis];'; 74 | $digraph .= '}'; 75 | 76 | $digraph .= sprintf('%s -> %s [lhead=cluster_%s];', $forkName, $firstNodeSubWorkflow, $subWorkflowName); 77 | $digraph .= sprintf('%s -> %s [ltail=cluster_%s];', $endDummyNodeSubWorkflow, $mergeName, $subWorkflowName); 78 | } 79 | 80 | $workflowStateName = $mergeName; 81 | } 82 | 83 | if (isset($stateProperties['targets'])) { 84 | foreach ($stateProperties['targets'] as $targetState => $targetGuard) { 85 | $digraph .= sprintf('%s -> %s [label="%s"];', $workflowStateName, $workflowName . '_' . $targetState, isset($targetGuard['guard']) ? preg_quote($targetGuard['guard'], '"') : null); 86 | } 87 | $digraph .= sprintf('%s [label=%s];', $workflowStateName, $state); 88 | } else { 89 | $digraph .= sprintf('%s [label=%s, shape=doublecircle];', $workflowStateName, $state); 90 | } 91 | } 92 | 93 | return $digraph; 94 | } 95 | 96 | /** 97 | * @param string $digraph 98 | */ 99 | protected function runGraphviz($digraph) 100 | { 101 | 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Guard/HistoryGuard.php: -------------------------------------------------------------------------------- 1 | processCondition($condition, $this->getStatusSets($context, false), (int) $countEntries); 23 | } 24 | 25 | /** 26 | * @param ContextInterface $context 27 | * @param $condition 28 | * @param $countEntries 29 | * @return bool 30 | * @throws InvalidRunnableExpressionException 31 | */ 32 | public function statusReentries(ContextInterface $context, $condition, $countEntries) 33 | { 34 | return $this->processCondition($condition, $this->getStatusSets($context, true) - 1, (int) $countEntries); 35 | } 36 | 37 | /** 38 | * @param string $condition 39 | * @param mixed $leftValue 40 | * @param mixed $rightValue 41 | * @return bool 42 | * @throws InvalidRunnableExpressionException 43 | */ 44 | protected function processCondition($condition, $leftValue, $rightValue) 45 | { 46 | switch ($condition) { 47 | case '=': 48 | case '==': 49 | return $leftValue == $rightValue; 50 | case '>': 51 | return $leftValue > $rightValue; 52 | case '>=': 53 | return $leftValue >= $rightValue; 54 | case '<': 55 | return $leftValue < $rightValue; 56 | case '<=': 57 | return $leftValue <= $rightValue; 58 | case '!=': 59 | return $leftValue != $rightValue; 60 | default: 61 | throw new InvalidHistoryGuardConditionException($condition); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Guard/StatusHistoryAware.php: -------------------------------------------------------------------------------- 1 | getStateHistory(); 16 | $historySize = count($statusHistory); 17 | $count = 1; 18 | 19 | if ($onlyContinuous) { 20 | while (++$count <= $historySize && $statusHistory[$historySize - $count] === $context->getCurrentState()) ; 21 | } else { 22 | foreach ($statusHistory as $status) { 23 | if ($status === $context->getCurrentState()) { 24 | $count++; 25 | } 26 | } 27 | } 28 | 29 | return $count - 1; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Guard/TimerGuard.php: -------------------------------------------------------------------------------- 1 | 2) { 19 | $timerToUse = $this->getStatusSets($context, false); 20 | $timeoutIntervalSpec = func_get_arg(min(func_num_args() - 1, $timerToUse)); 21 | } 22 | 23 | $timeoutExpiresAt = clone $context->getLastStateChangedAt(); 24 | $timeoutExpiresAt->modify($timeoutIntervalSpec); 25 | 26 | $timezone = $timeoutExpiresAt->getTimezone(); 27 | $now = new \DateTime('now', $timezone); 28 | 29 | return $timeoutExpiresAt <= $now; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Guard/VarGuard.php: -------------------------------------------------------------------------------- 1 | getVariable($variableName) === $content; 18 | } 19 | 20 | /** 21 | * @param ContextInterface $context 22 | * @param string $variableName 23 | * @param array $options 24 | * @return bool 25 | */ 26 | public function contentIn(ContextInterface $context, $variableName, array $options) 27 | { 28 | return in_array($context->getVariable($variableName), $options); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Handler/ContextHandler.php: -------------------------------------------------------------------------------- 1 | createChildContext($context, $workflowName); 17 | $context->addChildContext($childContext); 18 | 19 | return $childContext; 20 | } 21 | 22 | /** 23 | * @param ContextInterface $parentContext 24 | * @param string $workflowName 25 | * @return ContextInterface 26 | */ 27 | abstract protected function createChildContext(ContextInterface $parentContext, $workflowName); 28 | 29 | /** 30 | * @param ContextInterface $context 31 | */ 32 | public function finish(ContextInterface $context) 33 | { 34 | $context->finish(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Handler/ContextHandlerInterface.php: -------------------------------------------------------------------------------- 1 | \'[^\\\\\']*+(?:\\\\.[^\\\\\']*+)*+\') 16 | (?"[^\\\\"]*+(?:\\\\.[^\\\\"]*+)*+") 17 | (?(?&singleQuotedString)|(?&doubleQuotedString)) 18 | (?true|false) 19 | (?\d+(?:\.\d+)?) 20 | (?\[(?:(?&arguments)|)]) 21 | (?(?&string)|(?&boolean)|(?&number)|(?&array)) 22 | (?\s*(?&argument)\s*,(?&arguments)|\s*(?&argument)\s*) 23 | ) 24 | '; 25 | 26 | /** 27 | * @var \ArrayAccess 28 | */ 29 | protected $definitions; 30 | 31 | /** 32 | * @var \ArrayAccess 33 | */ 34 | protected $actions; 35 | 36 | /** 37 | * @var \ArrayAccess 38 | */ 39 | protected $guards; 40 | 41 | /** 42 | * @var EventDispatcherInterface 43 | */ 44 | protected $eventDispatcher; 45 | 46 | /** 47 | * @var ContextHandlerInterface 48 | */ 49 | protected $contextHandler; 50 | 51 | public function __construct( 52 | \ArrayAccess $definitions, 53 | \ArrayAccess $guards, 54 | \ArrayAccess $actions, 55 | EventDispatcherInterface $eventDispatcher, 56 | ContextHandlerInterface $contextHandler 57 | ) 58 | { 59 | $this->definitions = $definitions; 60 | $this->guards = $guards; 61 | $this->actions = $actions; 62 | $this->eventDispatcher = $eventDispatcher; 63 | $this->contextHandler = $contextHandler; 64 | } 65 | 66 | /** 67 | * @param ContextInterface $context 68 | * @return array 69 | * @throws InvalidWorkflowDefinitionException 70 | */ 71 | public function getAvailableEvents(ContextInterface $context) 72 | { 73 | $workingDefinition = $this->definitions[$context->getWorkflowName()]; 74 | $this->ensureContextState($context, $workingDefinition); 75 | 76 | $currentStateProperties = $workingDefinition[$context->getCurrentState()]; 77 | $availableEvents = []; 78 | if (isset($currentStateProperties['targets'])) { 79 | foreach ($currentStateProperties['targets'] as $transition) { 80 | if (isset($transition['event']) && !in_array($transition['event'], $availableEvents) && 81 | (!isset($transition['guard']) || $this->runCommand($context, $this->guards, $transition['guard'], WorkflowEvents::ON_GUARD_ERROR)) 82 | ) { 83 | $availableEvents[] = $transition['event']; 84 | } 85 | } 86 | } 87 | 88 | foreach ($context->getActiveChildrenContexts() as $childContext) { 89 | $availableEvents = array_merge($availableEvents, $this->getAvailableEvents($childContext)); 90 | } 91 | 92 | return array_unique($availableEvents); 93 | } 94 | 95 | protected function ensureContextState(ContextInterface $context, array $workingDefinition) 96 | { 97 | if ($context->getCurrentState() === null) { 98 | reset($workingDefinition); 99 | $context->setCurrentState(key($workingDefinition)); 100 | $this->runStateActions($context, $workingDefinition, 'onEntry'); 101 | 102 | } elseif (!array_key_exists($context->getCurrentState(), $workingDefinition)) { 103 | throw new InvalidWorkflowDefinitionException(); 104 | } 105 | } 106 | 107 | /** 108 | * @param ContextInterface $context 109 | * @param string $event 110 | * @throws InvalidWorkflowDefinitionException 111 | */ 112 | public function execute(ContextInterface $context, $event = null) 113 | { 114 | $workingDefinition = $this->definitions[$context->getWorkflowName()]; 115 | 116 | $this->ensureContextState($context, $workingDefinition); 117 | 118 | $this->eventDispatcher->dispatch(WorkflowEvents::BEGIN_EXECUTION, new WorkflowEvent($context)); 119 | $this->run($context, $workingDefinition, $event); 120 | $this->eventDispatcher->dispatch(WorkflowEvents::END_EXECUTION, new WorkflowEvent($context)); 121 | } 122 | 123 | /** 124 | * @param $workingDefinition 125 | * @param ContextInterface $context 126 | * @param $event 127 | */ 128 | protected function run(ContextInterface $context, array $workingDefinition, $event) 129 | { 130 | do { 131 | // Execute any parallel executions available in this context 132 | if ($context->hasActiveChildrenContexts()) { 133 | $exception = null; 134 | foreach ($context->getActiveChildrenContexts() as $childContext) { 135 | try { 136 | $this->execute($childContext, $event); 137 | 138 | } catch (\Exception $e) { 139 | // store the first workflow exception to throw away 140 | if (!$exception) { 141 | $exception = $e; 142 | } 143 | } 144 | } 145 | 146 | if ($exception) { 147 | throw $exception; 148 | } 149 | } 150 | 151 | $currentStateProperties = $workingDefinition[$context->getCurrentState()]; 152 | 153 | // Pause execution if any child is still running 154 | if ($context->hasActiveChildrenContexts()) { 155 | break; 156 | 157 | // End execution when there's no targets defined and try to continue the parent context 158 | } elseif (empty($currentStateProperties['targets'])) { 159 | $this->contextHandler->finish($context); 160 | 161 | break; 162 | } 163 | 164 | $nextState = $this->getTargetStateAvailable($context, $currentStateProperties['targets'], $event); 165 | if ($nextState === null) { 166 | break; 167 | } 168 | 169 | $this->moveToTargetState($context, $workingDefinition, $nextState); 170 | 171 | $currentStateProperties = $workingDefinition[$context->getCurrentState()]; 172 | if (isset($currentStateProperties['parallel'])) { 173 | $this->forkWorkflow($context, $currentStateProperties['parallel']); 174 | } 175 | 176 | $event = null; 177 | } while (true); 178 | } 179 | 180 | /** 181 | * @param ContextInterface $context 182 | * @param array $targets 183 | * @param string $event 184 | * @return string 185 | */ 186 | protected function getTargetStateAvailable(ContextInterface $context, $targets, $event) 187 | { 188 | $triggeredEventTransitions = []; 189 | $defaultTransitions = []; 190 | 191 | foreach ($targets as $targetState => $transition) { 192 | if (!isset($transition['event'])) { 193 | $defaultTransitions[] = [ 194 | 'targetState' => $targetState , 195 | 'transition' => $transition 196 | ]; 197 | } elseif ($event !== null && $transition['event'] == $event) { 198 | $triggeredEventTransitions[] = [ 199 | 'targetState' => $targetState , 200 | 'transition' => $transition 201 | ]; 202 | } 203 | } 204 | 205 | foreach ($triggeredEventTransitions + $defaultTransitions as $stateTransition) { 206 | if (!isset($stateTransition['transition']['guard']) || $this->runCommand($context, $this->guards, $stateTransition['transition']['guard'], WorkflowEvents::ON_GUARD_ERROR)) { 207 | return $stateTransition['targetState']; 208 | } 209 | } 210 | 211 | return null; 212 | } 213 | 214 | /** 215 | * @param ContextInterface $context 216 | * @param \ArrayAccess $container 217 | * @param string $expression 218 | * @param string $eventErrorToRaise 219 | * @return bool 220 | * @throws InvalidRunnableExpressionException 221 | * @throws \Exception 222 | */ 223 | protected function runCommand(ContextInterface $context, \ArrayAccess $container, $expression, $eventErrorToRaise) 224 | { 225 | if (!preg_match('/' . self::REGEX_DEFINITION . '^\s*(?[\w\.]+):(?[\w]+)(?:\((?(?&arguments)?)\))?\s*$/x', $expression, $serviceMatch)) { 226 | throw new InvalidRunnableExpressionException($expression); 227 | } 228 | 229 | $parameters = [ $context ]; 230 | if (isset($serviceMatch['method_arguments'])) { 231 | $parameters = array_merge($parameters, json_decode('[' . $serviceMatch['method_arguments'] . ']', true)); 232 | } 233 | 234 | try { 235 | $result = call_user_func_array([$container[$serviceMatch['service']], $serviceMatch['method']], $parameters); 236 | } catch (\Exception $e) { 237 | if ($this->eventDispatcher->hasListeners($eventErrorToRaise)) { 238 | $this->eventDispatcher->dispatch($eventErrorToRaise, new WorkflowExceptionEvent($context, $e, $expression)); 239 | $result = false; 240 | } else { 241 | throw new \Exception($e->getMessage() . ' when running ' . $expression, 0, $e); 242 | } 243 | } 244 | 245 | return $result; 246 | } 247 | 248 | /** 249 | * @param $workingDefinition 250 | * @param ContextInterface $context 251 | * @param $nextState 252 | * @param $transition 253 | */ 254 | protected function moveToTargetState(ContextInterface $context, array $workingDefinition, $nextState) 255 | { 256 | $this->eventDispatcher->dispatch(WorkflowEvents::BEGIN_TRANSITION, new WorkflowEvent($context)); 257 | 258 | $this->runStateActions($context, $workingDefinition, 'onExit'); 259 | $context->setCurrentState($nextState); 260 | 261 | $this->eventDispatcher->dispatch(WorkflowEvents::STATE_CHANGED, new WorkflowEvent($context)); 262 | 263 | $this->runStateActions($context, $workingDefinition, 'onEntry'); 264 | 265 | $this->eventDispatcher->dispatch(WorkflowEvents::END_TRANSITION, new WorkflowEvent($context)); 266 | } 267 | 268 | /** 269 | * @param ContextInterface $context 270 | * @param array $workingDefinition 271 | * @param string $stateActionType 272 | * @throws InvalidRunnableExpressionException 273 | * @throws \Exception 274 | */ 275 | protected function runStateActions(ContextInterface $context, array $workingDefinition, $stateActionType) 276 | { 277 | $raiseEventQueue = []; 278 | 279 | if (isset($workingDefinition[$context->getCurrentState()][$stateActionType])) { 280 | foreach ($workingDefinition[$context->getCurrentState()][$stateActionType] as $stateTypeAction) { 281 | if (isset($stateTypeAction['action'])) { 282 | $this->runCommand($context, $this->actions, $stateTypeAction['action'], WorkflowEvents::ON_ACTION_ERROR); 283 | 284 | } elseif (isset($stateTypeAction['raise'])) { 285 | $raiseEventQueue[] = $stateTypeAction['raise']; 286 | } 287 | } 288 | } 289 | 290 | if (count($raiseEventQueue)) { 291 | while ($context->getParentContext()) { 292 | $context = $context->getParentContext(); 293 | } 294 | 295 | foreach ($raiseEventQueue as $raiseEvent) { 296 | $this->execute($context, $raiseEvent); 297 | } 298 | } 299 | } 300 | 301 | /** 302 | * @param ContextInterface $context 303 | * @param array $subWorkflowNames 304 | */ 305 | protected function forkWorkflow(ContextInterface $context, $subWorkflowNames) 306 | { 307 | foreach ($subWorkflowNames as $workflowName) { 308 | $this->contextHandler->forkContext($context, $workflowName); 309 | } 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /src/WorkflowEvent.php: -------------------------------------------------------------------------------- 1 | context = $context; 17 | } 18 | 19 | /** 20 | * @return ContextInterface 21 | */ 22 | public function getContext() 23 | { 24 | return $this->context; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/WorkflowEvents.php: -------------------------------------------------------------------------------- 1 | exception = $exception; 22 | $this->expression = $expression; 23 | } 24 | 25 | /** 26 | * @return \Exception 27 | */ 28 | public function getException() 29 | { 30 | return $this->exception; 31 | } 32 | 33 | /** 34 | * @return string 35 | */ 36 | public function getExpression() 37 | { 38 | return $this->expression; 39 | } 40 | } 41 | --------------------------------------------------------------------------------